@classytic/promo 0.2.0 → 0.2.1

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/dist/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  import { a as PROGRAM_TYPES, c as TRIGGER_MODES, i as PROGRAM_TRANSITIONS, l as VOUCHER_STATUSES, n as DISCOUNT_SCOPES, o as REWARD_TYPES, r as PROGRAM_STATUSES, s as STACKING_MODES, t as DISCOUNT_MODES } from "./constants-D0Rntp2f.mjs";
2
+ import { Repository, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
2
3
  import { resolveTenantConfig } from "@classytic/primitives/tenant";
3
4
  import { createEvent, matchEventPattern } from "@classytic/primitives/events";
4
- import mongoose from "mongoose";
5
- import { Repository, multiTenantPlugin } from "@classytic/mongokit";
5
+ import mongoose, { Schema } from "mongoose";
6
6
  import { createHash, randomBytes } from "node:crypto";
7
7
  import { z } from "zod";
8
8
  //#region src/domain/errors/base.ts
@@ -23,6 +23,33 @@ var ProgramNotFoundError = class extends PromoError {
23
23
  super(id ? `Program '${id}' not found` : "Program not found");
24
24
  }
25
25
  };
26
+ /**
27
+ * Raised when a commit-time atomic CAS on `usedCount < maxUsageTotal`
28
+ * fails — i.e. the program's cap was exhausted by another concurrent
29
+ * commit between this caller's evaluate and its commit. The host should
30
+ * surface this as a "promo no longer available" failure to the user; the
31
+ * order itself can still proceed without the discount if the host wishes.
32
+ */
33
+ var ProgramUsageCapExceededError = class extends PromoError {
34
+ code = "PROGRAM_USAGE_CAP_EXCEEDED";
35
+ constructor(programId, maxUsageTotal) {
36
+ super(`Program '${programId}' has reached its usage cap of ${maxUsageTotal}`);
37
+ this.programId = programId;
38
+ this.maxUsageTotal = maxUsageTotal;
39
+ }
40
+ };
41
+ var RuleNotFoundError = class extends PromoError {
42
+ code = "RULE_NOT_FOUND";
43
+ constructor(id) {
44
+ super(id ? `Rule '${id}' not found` : "Rule not found");
45
+ }
46
+ };
47
+ var RewardNotFoundError = class extends PromoError {
48
+ code = "REWARD_NOT_FOUND";
49
+ constructor(id) {
50
+ super(id ? `Reward '${id}' not found` : "Reward not found");
51
+ }
52
+ };
26
53
  var VoucherNotFoundError = class extends PromoError {
27
54
  code = "VOUCHER_NOT_FOUND";
28
55
  constructor(codeOrId) {
@@ -81,6 +108,12 @@ var InsufficientBalanceError = class extends PromoError {
81
108
  super(`Voucher '${code}' has insufficient balance: ${available} available, ${requested} requested`);
82
109
  }
83
110
  };
111
+ var TenantIsolationError = class extends PromoError {
112
+ code = "TENANT_ISOLATION";
113
+ constructor() {
114
+ super("Tenant context is required but was not provided");
115
+ }
116
+ };
84
117
  var DuplicateRedemptionError = class extends PromoError {
85
118
  code = "DUPLICATE_REDEMPTION";
86
119
  constructor(key) {
@@ -169,10 +202,81 @@ function applyUserIndexes(schema, indexes, tenant) {
169
202
  }
170
203
  }
171
204
  //#endregion
205
+ //#region src/models/schemas/pending-evaluation.schema.ts
206
+ /**
207
+ * Persisted snapshot of an `evaluate()` outcome, awaiting a follow-up
208
+ * `commit()` or `rollback()`.
209
+ *
210
+ * Why a Mongo collection (not a process-local Map): pending evaluations
211
+ * MUST survive process restart, horizontal scaling, serverless cold
212
+ * starts, and worker handoff. Stale snapshots auto-clean via the TTL
213
+ * index — same pattern cart uses for guest drafts (see
214
+ * cart/src/models/draft.model.ts:131-135).
215
+ *
216
+ * Stored fields are intentionally minimal — only what `commit()` and
217
+ * `rollback()` need:
218
+ * - the materialised result (returned to caller for telemetry)
219
+ * - per-program / per-voucher usages (incremented atomically at commit)
220
+ * - cartHash (anti-tamper guard between evaluate and commit)
221
+ * - expiresAt (TTL drives auto-cleanup; engine never relies on it for
222
+ * correctness — the atomic `findOneAndDelete` in `take()` is the
223
+ * authoritative single-commit guard)
224
+ */
225
+ function createPendingEvaluationSchema() {
226
+ return new Schema({
227
+ evaluationId: {
228
+ type: String,
229
+ required: true,
230
+ unique: true,
231
+ index: true
232
+ },
233
+ result: {
234
+ type: Schema.Types.Mixed,
235
+ required: true
236
+ },
237
+ ctx: {
238
+ type: Schema.Types.Mixed,
239
+ required: true
240
+ },
241
+ customerId: {
242
+ type: String,
243
+ default: null
244
+ },
245
+ programUsages: {
246
+ type: [Schema.Types.Mixed],
247
+ default: []
248
+ },
249
+ voucherUsages: {
250
+ type: [Schema.Types.Mixed],
251
+ default: []
252
+ },
253
+ cartHash: {
254
+ type: String,
255
+ required: true,
256
+ index: true
257
+ },
258
+ expiresAt: {
259
+ type: Date,
260
+ required: true
261
+ }
262
+ }, {
263
+ timestamps: true,
264
+ minimize: false
265
+ });
266
+ }
267
+ /**
268
+ * Wire the TTL index. Mongo evaluates `expireAfterSeconds: 0` against the
269
+ * `expiresAt` field's value — when `expiresAt < now`, the doc is purged
270
+ * by the background TTL monitor (~60s granularity).
271
+ */
272
+ function applyPendingEvaluationIndexes(schema) {
273
+ schema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
274
+ }
275
+ //#endregion
172
276
  //#region src/models/schemas/program.schema.ts
173
- const { Schema: Schema$3 } = mongoose;
277
+ const { Schema: Schema$4 } = mongoose;
174
278
  function createProgramSchema() {
175
- const schema = new Schema$3({
279
+ const schema = new Schema$4({
176
280
  name: {
177
281
  type: String,
178
282
  required: true
@@ -217,7 +321,7 @@ function createProgramSchema() {
217
321
  of: Number,
218
322
  default: () => /* @__PURE__ */ new Map()
219
323
  },
220
- metadata: { type: Schema$3.Types.Mixed }
324
+ metadata: { type: Schema$4.Types.Mixed }
221
325
  }, { timestamps: true });
222
326
  schema.index({
223
327
  status: 1,
@@ -238,14 +342,14 @@ function createProgramSchema() {
238
342
  }
239
343
  //#endregion
240
344
  //#region src/models/schemas/reward.schema.ts
241
- const { Schema: Schema$2 } = mongoose;
345
+ const { Schema: Schema$3 } = mongoose;
242
346
  function createRewardSchema() {
243
- const schema = new Schema$2({
347
+ const schema = new Schema$3({
244
348
  programId: {
245
- type: Schema$2.Types.ObjectId,
349
+ type: Schema$3.Types.ObjectId,
246
350
  required: true
247
351
  },
248
- ruleId: { type: Schema$2.Types.ObjectId },
352
+ ruleId: { type: Schema$3.Types.ObjectId },
249
353
  rewardType: {
250
354
  type: String,
251
355
  enum: REWARD_TYPES,
@@ -270,7 +374,7 @@ function createRewardSchema() {
270
374
  default: 1
271
375
  },
272
376
  giftCardAmount: { type: Number },
273
- metadata: { type: Schema$2.Types.Mixed }
377
+ metadata: { type: Schema$3.Types.Mixed }
274
378
  }, { timestamps: true });
275
379
  schema.index({ programId: 1 });
276
380
  schema.index({ ruleId: 1 }, { sparse: true });
@@ -278,11 +382,11 @@ function createRewardSchema() {
278
382
  }
279
383
  //#endregion
280
384
  //#region src/models/schemas/rule.schema.ts
281
- const { Schema: Schema$1 } = mongoose;
385
+ const { Schema: Schema$2 } = mongoose;
282
386
  function createRuleSchema() {
283
- const schema = new Schema$1({
387
+ const schema = new Schema$2({
284
388
  programId: {
285
- type: Schema$1.Types.ObjectId,
389
+ type: Schema$2.Types.ObjectId,
286
390
  required: true
287
391
  },
288
392
  name: { type: String },
@@ -305,7 +409,7 @@ function createRuleSchema() {
305
409
  },
306
410
  startsAt: { type: Date },
307
411
  endsAt: { type: Date },
308
- metadata: { type: Schema$1.Types.Mixed }
412
+ metadata: { type: Schema$2.Types.Mixed }
309
413
  }, { timestamps: true });
310
414
  schema.index({ programId: 1 });
311
415
  schema.index({ code: 1 }, { sparse: true });
@@ -313,11 +417,11 @@ function createRuleSchema() {
313
417
  }
314
418
  //#endregion
315
419
  //#region src/models/schemas/voucher.schema.ts
316
- const { Schema } = mongoose;
420
+ const { Schema: Schema$1 } = mongoose;
317
421
  function createVoucherSchema() {
318
- const schema = new Schema({
422
+ const schema = new Schema$1({
319
423
  programId: {
320
- type: Schema.Types.ObjectId,
424
+ type: Schema$1.Types.ObjectId,
321
425
  required: true,
322
426
  index: true
323
427
  },
@@ -354,7 +458,8 @@ function createVoucherSchema() {
354
458
  type: Date,
355
459
  default: Date.now
356
460
  },
357
- idempotencyKey: { type: String }
461
+ idempotencyKey: { type: String },
462
+ organizationId: { type: String }
358
463
  }],
359
464
  expiresAt: { type: Date },
360
465
  redemptions: [{
@@ -371,9 +476,10 @@ function createVoucherSchema() {
371
476
  type: Date,
372
477
  default: Date.now
373
478
  },
374
- idempotencyKey: { type: String }
479
+ idempotencyKey: { type: String },
480
+ organizationId: { type: String }
375
481
  }],
376
- metadata: { type: Schema.Types.Mixed }
482
+ metadata: { type: Schema$1.Types.Mixed }
377
483
  }, { timestamps: true });
378
484
  schema.index({ code: 1 }, { unique: true });
379
485
  schema.index({
@@ -393,7 +499,8 @@ const MODEL_NAMES = [
393
499
  "PromoProgram",
394
500
  "PromoRule",
395
501
  "PromoReward",
396
- "PromoVoucher"
502
+ "PromoVoucher",
503
+ "PromoPendingEvaluation"
397
504
  ];
398
505
  function applyAutoIndex(models, autoIndex) {
399
506
  if (autoIndex === void 0) return;
@@ -413,10 +520,13 @@ function createModels(connection, tenant, indexes, autoIndex) {
413
520
  const ruleSchema = createRuleSchema();
414
521
  const rewardSchema = createRewardSchema();
415
522
  const voucherSchema = createVoucherSchema();
523
+ const pendingEvaluationSchema = createPendingEvaluationSchema();
416
524
  injectTenantField(programSchema, tenant);
417
525
  injectTenantField(ruleSchema, tenant);
418
526
  injectTenantField(rewardSchema, tenant);
419
527
  injectTenantField(voucherSchema, tenant);
528
+ injectTenantField(pendingEvaluationSchema, tenant);
529
+ applyPendingEvaluationIndexes(pendingEvaluationSchema);
420
530
  if (indexes?.program) applyUserIndexes(programSchema, indexes.program, tenant);
421
531
  if (indexes?.rule) applyUserIndexes(ruleSchema, indexes.rule, tenant);
422
532
  if (indexes?.reward) applyUserIndexes(rewardSchema, indexes.reward, tenant);
@@ -425,12 +535,94 @@ function createModels(connection, tenant, indexes, autoIndex) {
425
535
  Program: connection.model("PromoProgram", programSchema),
426
536
  Rule: connection.model("PromoRule", ruleSchema),
427
537
  Reward: connection.model("PromoReward", rewardSchema),
428
- Voucher: connection.model("PromoVoucher", voucherSchema)
538
+ Voucher: connection.model("PromoVoucher", voucherSchema),
539
+ PendingEvaluation: connection.model("PromoPendingEvaluation", pendingEvaluationSchema)
429
540
  };
430
541
  applyAutoIndex(result, autoIndex);
431
542
  return result;
432
543
  }
433
544
  //#endregion
545
+ //#region src/repositories/pending-evaluation.repository.ts
546
+ /**
547
+ * Pending-evaluation repository. Extends mongokit's `Repository<TDoc>`
548
+ * directly per package rules (no service wrapper, no aliased verbs).
549
+ * Adds one custom domain method: `takeByEvaluationId` — atomic
550
+ * read-and-delete via raw `Model.findOneAndDelete`.
551
+ *
552
+ * **Why raw `findOneAndDelete` (escape from mongokit's `delete()`)**:
553
+ * mongokit's `Repository.delete()` returns `{success, message}` only —
554
+ * it doesn't surface the deleted document. `take` semantics require
555
+ * "atomically remove AND return", which is the canonical defence
556
+ * against double-commit on the same evaluationId at the storage layer
557
+ * (one caller wins the document, the other gets `null`). This is the
558
+ * narrow exception PACKAGE_RULES.md / order/CLAUDE.md sanction:
559
+ * *"Raw findOneAndUpdate/findOneAndDelete is allowed ONLY for atomic
560
+ * state-machine transitions — flag each one with a comment."*
561
+ */
562
+ var PendingEvaluationRepository = class extends Repository {
563
+ /**
564
+ * The repository owns its tenant config so its raw-driver methods
565
+ * (`findOneAndDelete`, `deleteOne`) can apply the SAME scoping rule
566
+ * the mongokit hook pipeline would apply on standard methods —
567
+ * specifically using `tenant.tenantField` (host-configurable as
568
+ * `organizationId`, `branchId`, `tenantId`, etc.) NOT a hardcoded
569
+ * `organizationId`. Without this, deployments that configure custom
570
+ * tenant fields would silently lose isolation on the cache layer.
571
+ */
572
+ tenant;
573
+ constructor(model, plugins = [], tenant) {
574
+ super(model, plugins);
575
+ this.tenant = tenant;
576
+ }
577
+ /** The host-configured tenant field name (or `undefined` if single-tenant). */
578
+ get tenantField() {
579
+ return this.tenant?.enabled ? this.tenant.tenantField : void 0;
580
+ }
581
+ /**
582
+ * Atomic read-and-delete by evaluationId. Two concurrent commit calls
583
+ * on the same id race here at the database layer — the winner gets
584
+ * the document, the loser gets `null` and the calling service throws
585
+ * `EvaluationNotFoundError`. No way both succeed.
586
+ *
587
+ * Honours `ctx.session` so the operation joins the caller's
588
+ * transaction. If the transaction aborts (transient DB error, cap
589
+ * exceeded, etc.) the delete rolls back and the snapshot stays in
590
+ * the store — letting the caller retry without re-evaluation.
591
+ *
592
+ * Tenant scoping uses the configured `tenant.tenantField` (NOT a
593
+ * hardcoded `organizationId`) so hosts on `branchId`/`tenantId`/etc.
594
+ * keep cross-tenant isolation on the cache layer too.
595
+ */
596
+ async takeByEvaluationId(evaluationId, ctx) {
597
+ const filter = this.scopedFilter({ evaluationId }, ctx);
598
+ const session = ctx?.session ?? null;
599
+ return await this.Model.findOneAndDelete(filter).session(session).lean();
600
+ }
601
+ /**
602
+ * Idempotent delete. Returns whether anything was actually removed,
603
+ * so callers can distinguish "we cleaned it" from "already gone".
604
+ */
605
+ async deleteByEvaluationId(evaluationId, ctx) {
606
+ const filter = this.scopedFilter({ evaluationId }, ctx);
607
+ const session = ctx?.session ?? null;
608
+ return (await this.Model.deleteOne(filter).session(session)).deletedCount > 0;
609
+ }
610
+ /**
611
+ * Build a query filter with the configured tenant scope appended,
612
+ * mirroring what mongokit's `multiTenantPlugin` injects on standard
613
+ * Repository methods. We re-implement here because raw driver calls
614
+ * (`findOneAndDelete`, `deleteOne`) bypass the plugin pipeline.
615
+ */
616
+ scopedFilter(base, ctx) {
617
+ const field = this.tenantField;
618
+ if (!field || ctx?.tenantValue === void 0) return base;
619
+ return {
620
+ ...base,
621
+ [field]: ctx.tenantValue
622
+ };
623
+ }
624
+ };
625
+ //#endregion
434
626
  //#region src/events/dispatch.ts
435
627
  /**
436
628
  * Canonical P8 shape — save to outbox first (with `ctx.session` when
@@ -538,6 +730,34 @@ var ProgramRepository = class extends Repository {
538
730
  if (!updated) throw new ProgramNotFoundError(id);
539
731
  return updated;
540
732
  }
733
+ /**
734
+ * Atomic compare-and-set increment: succeeds only if the program either
735
+ * has no cap (`maxUsageTotal == null`) OR `usedCount < maxUsageTotal`.
736
+ * If the cap is already saturated, returns `null` so the caller can
737
+ * decide whether to throw (commit-time enforcement) or skip silently
738
+ * (best-effort eligibility check).
739
+ *
740
+ * Industry-standard primitive for "promo with finite supply" — without
741
+ * this, two evaluations both see the program as available and both
742
+ * commit, leading to oversell. The atomic filter on the same write
743
+ * eliminates the race entirely; concurrent losers see a `null` return
744
+ * and can downgrade their commit (apply order without the discount,
745
+ * surface "promo no longer available" to the user, etc.).
746
+ *
747
+ * Routes through mongokit's `update` so tenant scoping + hooks fire.
748
+ */
749
+ async tryIncrementUsage(id, ctx) {
750
+ return await this.update(id, { $inc: { usedCount: 1 } }, {
751
+ query: { $or: [
752
+ { maxUsageTotal: { $exists: false } },
753
+ { maxUsageTotal: null },
754
+ { $expr: { $lt: ["$usedCount", "$maxUsageTotal"] } }
755
+ ] },
756
+ throwOnNotFound: false,
757
+ lean: true,
758
+ ...ctx
759
+ }) ?? null;
760
+ }
541
761
  async decrementUsage(id, ctx) {
542
762
  const updated = await this.update(id, { $inc: { usedCount: -1 } }, {
543
763
  throwOnNotFound: true,
@@ -607,20 +827,27 @@ var RuleRepository = class extends Repository {
607
827
  var VoucherRepository = class extends Repository {
608
828
  dispatchDeps;
609
829
  tenantField;
610
- constructor(model, plugins = [], dispatchDeps = {}, tenantField = "organizationId") {
830
+ tenantEnabled;
831
+ constructor(model, plugins = [], dispatchDeps = {}, tenantField = "organizationId", tenantEnabled = true) {
611
832
  super(model, plugins);
612
833
  this.dispatchDeps = dispatchDeps;
613
834
  this.tenantField = tenantField;
835
+ this.tenantEnabled = tenantEnabled;
614
836
  }
615
837
  /**
616
838
  * Copy the tenant id from `ctx` onto the write payload so the doc persists
617
839
  * with the correct `organizationId` even when the host has opted OUT of the
618
- * auto-wired `multiTenantPlugin` (e.g. arc hosts that scope at the
619
- * framework layer — see `@classytic/order` CLAUDE.md: "Child repos set
620
- * `organizationId` explicitly on the doc"). No-op when `ctx` omits the
621
- * tenant id or the payload already carries it.
840
+ * auto-wired `multiTenantPlugin` but still scopes at its framework layer
841
+ * (e.g. arc's preset + `BaseController` — see `@classytic/order` CLAUDE.md:
842
+ * "Child repos set `organizationId` explicitly on the doc").
843
+ *
844
+ * Skipped entirely when the engine was configured with `tenant: false` —
845
+ * the host's intent is company-wide rows (no `organizationId` on disk),
846
+ * and injecting it from `ctx` would silently re-scope writes per branch
847
+ * while reads still look at the unscoped collection.
622
848
  */
623
849
  _injectTenant(data, ctx) {
850
+ if (!this.tenantEnabled) return data;
624
851
  const tenantValue = ctx?.[this.tenantField];
625
852
  if (tenantValue != null && data[this.tenantField] == null) data[this.tenantField] = tenantValue;
626
853
  return data;
@@ -722,11 +949,17 @@ var VoucherRepository = class extends Repository {
722
949
  * (e.g. arc's preset + `BaseController`). Without this, a host that
723
950
  * runs the plugin off would leak vouchers across branches at
724
951
  * validate/redeem/spend/topUp call sites.
952
+ *
953
+ * When the engine is configured with `tenant: false`, the filter is
954
+ * code-only — vouchers are company-wide and the docs carry no
955
+ * `organizationId`, so injecting one would always miss.
725
956
  */
726
957
  async getByCode(code, ctx) {
727
958
  const filter = { code: code.toUpperCase() };
728
- const tenantValue = ctx?.[this.tenantField];
729
- if (tenantValue != null) filter[this.tenantField] = tenantValue;
959
+ if (this.tenantEnabled) {
960
+ const tenantValue = ctx?.[this.tenantField];
961
+ if (tenantValue != null) filter[this.tenantField] = tenantValue;
962
+ }
730
963
  return this.getByQuery(filter, {
731
964
  throwOnNotFound: false,
732
965
  lean: true,
@@ -758,12 +991,148 @@ function createRepositories(models, plugins, tenant, dispatchDeps = {}) {
758
991
  program: new ProgramRepository(models.Program, [...tenantPlugins, ...plugins?.program ?? []], dispatchDeps),
759
992
  rule: new RuleRepository(models.Rule, [...tenantPlugins, ...plugins?.rule ?? []]),
760
993
  reward: new RewardRepository(models.Reward, [...tenantPlugins, ...plugins?.reward ?? []]),
761
- voucher: new VoucherRepository(models.Voucher, [...tenantPlugins, ...plugins?.voucher ?? []], dispatchDeps, tenant?.tenantField)
994
+ voucher: new VoucherRepository(models.Voucher, [...tenantPlugins, ...plugins?.voucher ?? []], dispatchDeps, tenant?.tenantField, tenant?.enabled ?? false),
995
+ pendingEvaluation: new PendingEvaluationRepository(models.PendingEvaluation, [...tenantPlugins, ...plugins?.pendingEvaluation ?? []], tenant)
762
996
  };
763
997
  }
764
998
  //#endregion
999
+ //#region src/adapters/mongo-evaluation-store.ts
1000
+ /**
1001
+ * Default Mongo-backed implementation of {@link EvaluationStore}. Translates
1002
+ * between the domain snapshot shape and the persistence document shape,
1003
+ * forwards atomic `take`/`delete` semantics to the repository, and
1004
+ * applies the engine's configured tenant field on writes (the repo
1005
+ * applies it on reads).
1006
+ *
1007
+ * Hosts that prefer a different backend (Redis, DynamoDB, in-memory for
1008
+ * tests) can implement `EvaluationStore` directly and pass it via engine
1009
+ * config — this class isn't load-bearing.
1010
+ */
1011
+ var MongoEvaluationStore = class {
1012
+ constructor(repo) {
1013
+ this.repo = repo;
1014
+ }
1015
+ async put(id, snapshot, ttlSeconds, ctx) {
1016
+ const expiresAt = new Date(Date.now() + ttlSeconds * 1e3);
1017
+ const session = ctx?.session;
1018
+ const tenantField = this.repo.tenantField;
1019
+ const tenantValue = ctx?.tenantValue ?? snapshot.ctx.organizationId;
1020
+ const filter = { evaluationId: id };
1021
+ if (tenantField && tenantValue !== void 0) filter[tenantField] = tenantValue;
1022
+ const update = {
1023
+ evaluationId: id,
1024
+ result: snapshot.result,
1025
+ ctx: snapshot.ctx,
1026
+ customerId: snapshot.customerId ?? null,
1027
+ programUsages: snapshot.programUsages,
1028
+ voucherUsages: snapshot.voucherUsages,
1029
+ cartHash: snapshot.cartHash,
1030
+ expiresAt
1031
+ };
1032
+ if (tenantField && tenantValue !== void 0) update[tenantField] = tenantValue;
1033
+ await this.repo.Model.updateOne(filter, { $set: update }, {
1034
+ upsert: true,
1035
+ session
1036
+ });
1037
+ }
1038
+ async take(id, ctx) {
1039
+ const doc = await this.repo.takeByEvaluationId(id, {
1040
+ tenantValue: ctx?.tenantValue,
1041
+ session: ctx?.session
1042
+ });
1043
+ if (!doc) return null;
1044
+ return {
1045
+ result: doc.result,
1046
+ ctx: doc.ctx,
1047
+ customerId: doc.customerId ?? void 0,
1048
+ programUsages: doc.programUsages,
1049
+ voucherUsages: doc.voucherUsages,
1050
+ cartHash: doc.cartHash,
1051
+ createdAt: doc.createdAt
1052
+ };
1053
+ }
1054
+ async delete(id, ctx) {
1055
+ await this.repo.deleteByEvaluationId(id, {
1056
+ tenantValue: ctx?.tenantValue,
1057
+ session: ctx?.session
1058
+ });
1059
+ }
1060
+ };
1061
+ /**
1062
+ * In-memory fallback. Useful for unit tests, single-process dev servers,
1063
+ * and hosts that consciously opt out of persistence (knowing they lose
1064
+ * pending evaluations on restart). NOT recommended for production
1065
+ * topologies — see EvaluationStore docblock for why.
1066
+ *
1067
+ * Tenant scoping uses `ctx.tenantValue` as part of the in-memory key
1068
+ * (the field-name layer doesn't matter here — we just namespace by
1069
+ * value). No transaction semantics (in-memory has nothing to roll
1070
+ * back), no TTL refinement beyond initial expiry check.
1071
+ */
1072
+ var InMemoryEvaluationStore = class {
1073
+ map = /* @__PURE__ */ new Map();
1074
+ async put(id, snapshot, ttlSeconds, ctx) {
1075
+ this.map.set(this.key(id, ctx), {
1076
+ snapshot,
1077
+ expiresAt: Date.now() + ttlSeconds * 1e3
1078
+ });
1079
+ }
1080
+ async take(id, ctx) {
1081
+ const k = this.key(id, ctx);
1082
+ const entry = this.map.get(k);
1083
+ if (!entry) return null;
1084
+ this.map.delete(k);
1085
+ if (entry.expiresAt < Date.now()) return null;
1086
+ return entry.snapshot;
1087
+ }
1088
+ async delete(id, ctx) {
1089
+ this.map.delete(this.key(id, ctx));
1090
+ }
1091
+ key(id, ctx) {
1092
+ return ctx?.tenantValue ? `${ctx.tenantValue}:${id}` : id;
1093
+ }
1094
+ };
1095
+ //#endregion
1096
+ //#region src/utils/is-write-conflict.ts
1097
+ /**
1098
+ * Detect MongoDB's transient WriteConflict error across the shapes it
1099
+ * arrives in (raw driver error, mongoose-wrapped VersionError, mongokit
1100
+ * rethrows). Centralised so every contended write site (gift card spend,
1101
+ * gift card top-up, evaluation commit) can opt into uniform mapping
1102
+ * to the same typed `ConcurrencyConflictError` / `*UsageCapExceededError`
1103
+ * shape — hosts catch one type, decide retry-or-409 once.
1104
+ *
1105
+ * Why detection is a layered match: under heavy contention MongoDB
1106
+ * surfaces the same physical race in different ways depending on which
1107
+ * layer caught it first (driver vs mongoose vs mongokit), and each
1108
+ * wrapper preserves a slightly different subset of the original error
1109
+ * fields. Matching against ALL of them avoids false negatives that
1110
+ * would otherwise let the raw error escape to the host with no useful
1111
+ * type information.
1112
+ */
1113
+ function isWriteConflict(err) {
1114
+ if (typeof err !== "object" || err === null) return false;
1115
+ const e = err;
1116
+ if (e.code === 112 || e.codeName === "WriteConflict") return true;
1117
+ if (e.name === "MongoServerError" && typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
1118
+ if (Array.isArray(e.errorLabels) && e.errorLabels.includes("TransientTransactionError")) return true;
1119
+ if (typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
1120
+ if (e.cause !== void 0) return isWriteConflict(e.cause);
1121
+ return false;
1122
+ }
1123
+ //#endregion
765
1124
  //#region src/services/evaluation.service.ts
766
1125
  /**
1126
+ * Default TTL for pending evaluation snapshots in the store. 30 minutes
1127
+ * comfortably exceeds any realistic evaluate→commit window (typical
1128
+ * checkout: seconds to a few minutes; long-tail: ~10 min) without
1129
+ * hoarding abandoned evaluations forever. The Mongo TTL index purges
1130
+ * expired entries; the engine itself never relies on the TTL for
1131
+ * correctness — `store.take()` is atomic and returns `null` when an
1132
+ * id was already consumed or never existed.
1133
+ */
1134
+ const DEFAULT_PENDING_EVALUATION_TTL_SECONDS = 1800;
1135
+ /**
767
1136
  * Deterministic, collision-resistant hash of the evaluated cart. The hash
768
1137
  * covers everything the evaluation algorithm depends on: normalized line
769
1138
  * items (sorted), subtotal, applied codes, and customer identity. Two
@@ -797,8 +1166,7 @@ function computeCartHash(input) {
797
1166
  return createHash("sha256").update(canonical).digest("hex");
798
1167
  }
799
1168
  var EvaluationService = class {
800
- pendingEvaluations = /* @__PURE__ */ new Map();
801
- constructor(programRepo, ruleRepo, rewardRepo, voucherRepo, unitOfWork, dispatchDeps, config) {
1169
+ constructor(programRepo, ruleRepo, rewardRepo, voucherRepo, unitOfWork, dispatchDeps, config, store) {
802
1170
  this.programRepo = programRepo;
803
1171
  this.ruleRepo = ruleRepo;
804
1172
  this.rewardRepo = rewardRepo;
@@ -806,6 +1174,7 @@ var EvaluationService = class {
806
1174
  this.unitOfWork = unitOfWork;
807
1175
  this.dispatchDeps = dispatchDeps;
808
1176
  this.config = config;
1177
+ this.store = store;
809
1178
  }
810
1179
  async evaluate(input, ctx) {
811
1180
  return this.doEvaluate(input, ctx, false);
@@ -814,50 +1183,80 @@ var EvaluationService = class {
814
1183
  return this.doEvaluate(input, ctx, true);
815
1184
  }
816
1185
  async commit(evaluationId, orderId, ctx, options = {}) {
817
- const stored = this.pendingEvaluations.get(evaluationId);
818
- if (!stored) throw new EvaluationNotFoundError(evaluationId);
819
- if (options.cartHash !== void 0 && options.cartHash !== stored.cartHash) throw new CartHashMismatchError(evaluationId);
820
- return this.unitOfWork.withTransaction(async (session) => {
821
- for (const usage of stored.programUsages) {
822
- await this.programRepo.incrementUsage(usage.programId, {
823
- ...ctx,
824
- session
825
- });
826
- if (stored.customerId) await this.programRepo.incrementCustomerUsage(usage.programId, stored.customerId, {
827
- ...ctx,
1186
+ let snapshotForCapMapping = null;
1187
+ try {
1188
+ return await this.unitOfWork.withTransaction(async (session) => {
1189
+ const stored = await this.store.take(evaluationId, {
1190
+ tenantValue: ctx.organizationId,
828
1191
  session
829
1192
  });
1193
+ if (!stored) throw new EvaluationNotFoundError(evaluationId);
1194
+ if (options.cartHash !== void 0 && options.cartHash !== stored.cartHash) throw new CartHashMismatchError(evaluationId);
1195
+ snapshotForCapMapping = stored;
1196
+ return await this.commitInTransaction(stored, evaluationId, orderId, session, ctx);
1197
+ });
1198
+ } catch (err) {
1199
+ if (isWriteConflict(err) && snapshotForCapMapping) {
1200
+ const firstUsage = snapshotForCapMapping.programUsages[0];
1201
+ if (firstUsage) {
1202
+ const program = await this.programRepo.getById(firstUsage.programId, {
1203
+ ...ctx,
1204
+ lean: true,
1205
+ throwOnNotFound: false
1206
+ });
1207
+ throw new ProgramUsageCapExceededError(firstUsage.programId, program?.maxUsageTotal ?? 0);
1208
+ }
830
1209
  }
831
- for (const usage of stored.voucherUsages) await this.voucherRepo.incrementUsage(usage.voucherId, {
832
- orderId,
833
- discountAmount: usage.discountAmount,
834
- redeemedAt: /* @__PURE__ */ new Date()
835
- }, {
1210
+ throw err;
1211
+ }
1212
+ }
1213
+ async commitInTransaction(stored, evaluationId, orderId, session, ctx) {
1214
+ for (const usage of stored.programUsages) {
1215
+ if (!await this.programRepo.tryIncrementUsage(usage.programId, {
836
1216
  ...ctx,
837
1217
  session
838
- });
839
- this.pendingEvaluations.delete(evaluationId);
840
- const commitResult = {
841
- evaluationId,
842
- orderId,
843
- totalDiscount: stored.result.totalDiscount,
844
- programsCommitted: stored.programUsages.length,
845
- vouchersUsed: stored.voucherUsages.length
846
- };
847
- await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMMITTED, {
848
- evaluationId,
849
- orderId,
850
- totalDiscount: stored.result.totalDiscount
851
- }, ctx), {
1218
+ })) {
1219
+ const program = await this.programRepo.getById(usage.programId, {
1220
+ ...ctx,
1221
+ session,
1222
+ lean: true,
1223
+ throwOnNotFound: false
1224
+ });
1225
+ throw new ProgramUsageCapExceededError(usage.programId, program?.maxUsageTotal ?? 0);
1226
+ }
1227
+ if (stored.customerId) await this.programRepo.incrementCustomerUsage(usage.programId, stored.customerId, {
852
1228
  ...ctx,
853
1229
  session
854
1230
  });
855
- return commitResult;
1231
+ }
1232
+ for (const usage of stored.voucherUsages) await this.voucherRepo.incrementUsage(usage.voucherId, {
1233
+ orderId,
1234
+ discountAmount: usage.discountAmount,
1235
+ redeemedAt: /* @__PURE__ */ new Date(),
1236
+ ...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
1237
+ }, {
1238
+ ...ctx,
1239
+ session
1240
+ });
1241
+ const commitResult = {
1242
+ evaluationId,
1243
+ orderId,
1244
+ totalDiscount: stored.result.totalDiscount,
1245
+ programsCommitted: stored.programUsages.length,
1246
+ vouchersUsed: stored.voucherUsages.length
1247
+ };
1248
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMMITTED, {
1249
+ evaluationId,
1250
+ orderId,
1251
+ totalDiscount: stored.result.totalDiscount
1252
+ }, ctx), {
1253
+ ...ctx,
1254
+ session
856
1255
  });
1256
+ return commitResult;
857
1257
  }
858
1258
  async rollback(evaluationId, ctx) {
859
- if (!this.pendingEvaluations.get(evaluationId)) return;
860
- this.pendingEvaluations.delete(evaluationId);
1259
+ await this.store.delete(evaluationId, { tenantValue: ctx.organizationId });
861
1260
  await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_ROLLED_BACK, { evaluationId }, ctx), ctx);
862
1261
  }
863
1262
  async doEvaluate(input, ctx, isPreview) {
@@ -970,15 +1369,18 @@ var EvaluationService = class {
970
1369
  isPreview,
971
1370
  programsApplied
972
1371
  };
973
- if (!isPreview) this.pendingEvaluations.set(evaluationId, {
974
- result,
975
- ctx,
976
- customerId: input.customerId,
977
- programUsages,
978
- voucherUsages,
979
- createdAt: Date.now(),
980
- cartHash
981
- });
1372
+ if (!isPreview) {
1373
+ const snapshot = {
1374
+ result,
1375
+ ctx,
1376
+ customerId: input.customerId,
1377
+ programUsages,
1378
+ voucherUsages,
1379
+ cartHash,
1380
+ createdAt: /* @__PURE__ */ new Date()
1381
+ };
1382
+ await this.store.put(evaluationId, snapshot, DEFAULT_PENDING_EVALUATION_TTL_SECONDS, { tenantValue: ctx.organizationId });
1383
+ }
982
1384
  await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMPLETED, {
983
1385
  evaluationId,
984
1386
  totalDiscount,
@@ -1267,14 +1669,18 @@ var VoucherService = class {
1267
1669
  if (!voucher) throw new VoucherNotFoundError(input.code);
1268
1670
  this.assertVoucherUsable(voucher);
1269
1671
  if (input.idempotencyKey) {
1270
- if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey)) throw new DuplicateRedemptionError(input.idempotencyKey);
1672
+ if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, {
1673
+ ...ctx,
1674
+ session
1675
+ })) throw new DuplicateRedemptionError(input.idempotencyKey);
1271
1676
  }
1272
1677
  const redemption = {
1273
1678
  orderId: input.orderId,
1274
1679
  customerId: input.customerId,
1275
1680
  discountAmount: input.discountAmount,
1276
1681
  redeemedAt: /* @__PURE__ */ new Date(),
1277
- ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
1682
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
1683
+ ...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
1278
1684
  };
1279
1685
  const updated = await this.voucherRepo.incrementUsage(voucher._id, redemption, {
1280
1686
  ...ctx,
@@ -1330,14 +1736,18 @@ var VoucherService = class {
1330
1736
  const balance = voucher.currentBalance ?? 0;
1331
1737
  if (!this.config.giftCard.allowNegativeBalance && balance < input.amount) throw new InsufficientBalanceError(input.code, balance, input.amount);
1332
1738
  if (input.idempotencyKey) {
1333
- if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey)) throw new DuplicateRedemptionError(input.idempotencyKey);
1739
+ if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, {
1740
+ ...ctx,
1741
+ session
1742
+ })) throw new DuplicateRedemptionError(input.idempotencyKey);
1334
1743
  }
1335
1744
  const entry = {
1336
1745
  amount: -input.amount,
1337
1746
  orderId: input.orderId,
1338
1747
  description: input.description ?? `Spent on order ${input.orderId}`,
1339
1748
  createdAt: /* @__PURE__ */ new Date(),
1340
- ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
1749
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
1750
+ ...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
1341
1751
  };
1342
1752
  const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, -input.amount, {
1343
1753
  ...ctx,
@@ -1380,37 +1790,61 @@ var VoucherService = class {
1380
1790
  }
1381
1791
  async topUp(input, ctx) {
1382
1792
  if (input.amount <= 0) throw new ValidationError("Top-up amount must be positive");
1383
- const voucher = await this.voucherRepo.getByCode(input.code, ctx);
1384
- if (!voucher) throw new VoucherNotFoundError(input.code);
1385
- const maxBalance = this.config.giftCard.maxBalance;
1386
- if (maxBalance !== null && (voucher.currentBalance ?? 0) + input.amount > maxBalance) throw new ValidationError(`Top-up would exceed max balance of ${maxBalance}`);
1387
- if (input.idempotencyKey) {
1388
- if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey)) throw new DuplicateRedemptionError(input.idempotencyKey);
1793
+ try {
1794
+ return await this.topUpInTransaction(input, ctx);
1795
+ } catch (err) {
1796
+ if (isWriteConflict(err)) throw new ConcurrencyConflictError("voucher", input.code, err);
1797
+ throw err;
1389
1798
  }
1390
- const entry = {
1391
- amount: input.amount,
1392
- description: input.description ?? "Top-up",
1393
- createdAt: /* @__PURE__ */ new Date(),
1394
- ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
1395
- };
1396
- const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, input.amount, ctx);
1397
- if (voucher.status === "used") await this.voucherRepo.update(voucher._id, { status: "active" }, {
1398
- lean: true,
1399
- ...ctx
1799
+ }
1800
+ async topUpInTransaction(input, ctx) {
1801
+ return this.unitOfWork.withTransaction(async (session) => {
1802
+ const voucher = await this.voucherRepo.getByCode(input.code, {
1803
+ ...ctx,
1804
+ session
1805
+ });
1806
+ if (!voucher) throw new VoucherNotFoundError(input.code);
1807
+ const maxBalance = this.config.giftCard.maxBalance;
1808
+ if (maxBalance !== null && (voucher.currentBalance ?? 0) + input.amount > maxBalance) throw new ValidationError(`Top-up would exceed max balance of ${maxBalance}`);
1809
+ if (input.idempotencyKey) {
1810
+ if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, {
1811
+ ...ctx,
1812
+ session
1813
+ })) throw new DuplicateRedemptionError(input.idempotencyKey);
1814
+ }
1815
+ const entry = {
1816
+ amount: input.amount,
1817
+ description: input.description ?? "Top-up",
1818
+ createdAt: /* @__PURE__ */ new Date(),
1819
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
1820
+ ...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
1821
+ };
1822
+ const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, input.amount, {
1823
+ ...ctx,
1824
+ session
1825
+ });
1826
+ if (voucher.status === "used") await this.voucherRepo.update(voucher._id, { status: "active" }, {
1827
+ lean: true,
1828
+ ...ctx,
1829
+ session
1830
+ });
1831
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_TOPPED_UP, {
1832
+ voucherId: voucher._id,
1833
+ code: voucher.code,
1834
+ amount: input.amount,
1835
+ newBalance: updated.currentBalance ?? 0
1836
+ }, ctx), {
1837
+ ...ctx,
1838
+ session
1839
+ });
1840
+ return {
1841
+ code: updated.code,
1842
+ initialBalance: updated.initialBalance ?? 0,
1843
+ currentBalance: updated.currentBalance ?? 0,
1844
+ spent: (updated.initialBalance ?? 0) - (updated.currentBalance ?? 0),
1845
+ voucherId: updated._id
1846
+ };
1400
1847
  });
1401
- await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_TOPPED_UP, {
1402
- voucherId: voucher._id,
1403
- code: voucher.code,
1404
- amount: input.amount,
1405
- newBalance: updated.currentBalance ?? 0
1406
- }, ctx), ctx);
1407
- return {
1408
- code: updated.code,
1409
- initialBalance: updated.initialBalance ?? 0,
1410
- currentBalance: updated.currentBalance ?? 0,
1411
- spent: (updated.initialBalance ?? 0) - (updated.currentBalance ?? 0),
1412
- voucherId: updated._id
1413
- };
1414
1848
  }
1415
1849
  assertVoucherUsable(voucher) {
1416
1850
  if (voucher.status === "expired") throw new VoucherExpiredError(voucher.code);
@@ -1423,61 +1857,19 @@ var VoucherService = class {
1423
1857
  if (voucher.usedCount >= voucher.usageLimit) throw new VoucherExhaustedError(voucher.code);
1424
1858
  }
1425
1859
  };
1426
- /**
1427
- * Detect MongoDB's transient WriteConflict error across the shapes it
1428
- * arrives in (raw driver error, mongoose-wrapped VersionError, mongokit
1429
- * rethrows). Centralised so every contended write site can opt-in.
1430
- */
1431
- function isWriteConflict(err) {
1432
- if (typeof err !== "object" || err === null) return false;
1433
- const e = err;
1434
- if (e.code === 112 || e.codeName === "WriteConflict") return true;
1435
- if (e.name === "MongoServerError" && typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
1436
- if (Array.isArray(e.errorLabels) && e.errorLabels.includes("TransientTransactionError")) return true;
1437
- if (typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
1438
- if (e.cause !== void 0) return isWriteConflict(e.cause);
1439
- return false;
1440
- }
1441
1860
  //#endregion
1442
1861
  //#region src/services/create-services.ts
1443
1862
  function createServices(deps) {
1444
- const { repositories, unitOfWork, dispatchDeps, config } = deps;
1863
+ const { repositories, unitOfWork, dispatchDeps, config, evaluationStore } = deps;
1864
+ const voucher = new VoucherService(repositories.voucher, repositories.program, unitOfWork, dispatchDeps, config);
1865
+ const store = evaluationStore ?? new MongoEvaluationStore(repositories.pendingEvaluation);
1445
1866
  return {
1446
- voucher: new VoucherService(repositories.voucher, repositories.program, unitOfWork, dispatchDeps, config),
1447
- evaluation: new EvaluationService(repositories.program, repositories.rule, repositories.reward, repositories.voucher, unitOfWork, dispatchDeps, config)
1867
+ voucher,
1868
+ evaluation: new EvaluationService(repositories.program, repositories.rule, repositories.reward, repositories.voucher, unitOfWork, dispatchDeps, config, store)
1448
1869
  };
1449
1870
  }
1450
1871
  //#endregion
1451
1872
  //#region src/events/promo-event-catalog.ts
1452
- /**
1453
- * Promo event catalog — Zod-source-of-truth definitions for every
1454
- * `promo.*` event.
1455
- *
1456
- * Each definition exposes:
1457
- * - `.zodSchema` — source of truth, used by host code's `.safeParse()`
1458
- * - `.schema` — JSON Schema derived via `z.toJSONSchema()`, consumed
1459
- * by Arc's EventRegistry + OpenAPI plugin
1460
- * - `.create(...)` — DomainEvent envelope builder, structurally compatible
1461
- * with `@classytic/arc`'s `EventDefinitionOutput`
1462
- *
1463
- * Structurally compatible with Arc 2.10's `EventRegistry` — hosts register
1464
- * `promoEventDefinitions` directly, no adapter code. Promo does NOT import
1465
- * from `@classytic/arc` (PACKAGE_RULES §11); compatibility is purely
1466
- * structural.
1467
- *
1468
- * Payload schemas mirror the typed interfaces in
1469
- * [event-payloads.ts](./event-payloads.ts). See PACKAGE_RULES §18.5 for
1470
- * the pattern.
1471
- *
1472
- * @example Wiring into an Arc app
1473
- * ```ts
1474
- * import { createEventRegistry } from '@classytic/arc/events';
1475
- * import { promoEventDefinitions } from '@classytic/promo';
1476
- *
1477
- * const registry = createEventRegistry();
1478
- * for (const def of promoEventDefinitions) registry.register(def);
1479
- * ```
1480
- */
1481
1873
  function definePromoEvent(input) {
1482
1874
  const { name, version = 1, description, zodSchema } = input;
1483
1875
  const def = {
@@ -1727,23 +2119,62 @@ function resolveConfig(config) {
1727
2119
  if (resolved.evaluation.maxStackablePromotions < 1) throw new ValidationError("maxStackablePromotions must be >= 1");
1728
2120
  return resolved;
1729
2121
  }
2122
+ /**
2123
+ * Thin adapter over `mongokit.withTransaction` so the engine's local
2124
+ * `UnitOfWork` port stays driver-agnostic while inheriting mongokit's
2125
+ * battle-tested transaction handling: auto-retry on
2126
+ * `TransientTransactionError` + `UnknownTransactionCommitResult`,
2127
+ * standalone-Mongo fallback for dev, consistent session lifecycle.
2128
+ *
2129
+ * Don't replace this with hand-rolled `session.withTransaction()` — the
2130
+ * underlying helper centralises retry semantics across every package
2131
+ * that wires it. See packages/mongokit/src/transaction.ts.
2132
+ */
1730
2133
  var MongoUnitOfWork = class {
1731
2134
  constructor(connection) {
1732
2135
  this.connection = connection;
1733
2136
  }
1734
- async withTransaction(cb) {
1735
- const session = await this.connection.startSession();
1736
- try {
1737
- let result;
1738
- await session.withTransaction(async () => {
1739
- result = await cb(session);
1740
- });
1741
- return result;
1742
- } finally {
1743
- await session.endSession();
1744
- }
2137
+ withTransaction(cb) {
2138
+ return withTransaction(this.connection, cb);
1745
2139
  }
1746
2140
  };
2141
+ /**
2142
+ * Build the promo engine for a host application.
2143
+ *
2144
+ * **Index management — important for boot performance:**
2145
+ *
2146
+ * `createPromoEngine` itself is non-blocking. It registers Mongoose models
2147
+ * and returns immediately. Index creation is delegated to Mongoose's
2148
+ * standard lazy-init: with `autoIndex: true` (default in dev) Mongoose
2149
+ * builds indexes in the background after the first query touches each
2150
+ * model; with `autoIndex: false` (recommended for production) hosts
2151
+ * control index creation explicitly.
2152
+ *
2153
+ * The returned `engine.syncIndexes()` helper is opt-in and **MUST NOT be
2154
+ * `await`ed during Fastify plugin registration / boot** — Atlas index
2155
+ * creation can take 10s+ on fresh collections, longer than typical
2156
+ * plugin timeouts. Three safe patterns:
2157
+ *
2158
+ * 1. **Migration script** (recommended for production):
2159
+ * ```ts
2160
+ * // scripts/sync-indexes.ts
2161
+ * await engine.syncIndexes();
2162
+ * ```
2163
+ * Run before deploying / serving traffic.
2164
+ *
2165
+ * 2. **Background fire-and-log** during boot:
2166
+ * ```ts
2167
+ * engine.syncIndexes().catch((err) => log.warn({ err }, 'index sync'));
2168
+ * ```
2169
+ * App accepts traffic immediately; first queries may wait briefly
2170
+ * on still-building indexes.
2171
+ *
2172
+ * 3. **Lazy init** (`autoIndex: true` in dev): just don't call
2173
+ * `syncIndexes()` at all. Mongoose schedules creation on first
2174
+ * query per model.
2175
+ *
2176
+ * Production hosts should set `autoIndex: false` and use option (1).
2177
+ */
1747
2178
  function createPromoEngine(config) {
1748
2179
  const resolvedConfig = resolveConfig(config);
1749
2180
  const models = createModels(config.mongoose, resolvedConfig.tenant, config.indexes, config.autoIndex);
@@ -1761,7 +2192,8 @@ function createPromoEngine(config) {
1761
2192
  repositories,
1762
2193
  unitOfWork: new MongoUnitOfWork(config.mongoose),
1763
2194
  dispatchDeps,
1764
- config: resolvedConfig
2195
+ config: resolvedConfig,
2196
+ ...config.evaluationStore !== void 0 ? { evaluationStore: config.evaluationStore } : {}
1765
2197
  }),
1766
2198
  events,
1767
2199
  async syncIndexes() {
@@ -1770,4 +2202,4 @@ function createPromoEngine(config) {
1770
2202
  };
1771
2203
  }
1772
2204
  //#endregion
1773
- export { EvaluationCommitted, EvaluationCompleted, EvaluationRolledBack, GiftCardExhausted, GiftCardSpent, GiftCardToppedUp, ProgramActivated, ProgramArchived, ProgramCreated, ProgramPaused, ProgramRepository, PromoEvents, RewardAdded, RewardRemoved, RewardRepository, RewardUpdated, RuleAdded, RuleRemoved, RuleRepository, RuleUpdated, VoucherCancelled, VoucherExpired, VoucherGenerated, VoucherRedeemed, VoucherRepository, createPromoEngine, promoEventDefinitions, resolveConfig };
2205
+ export { CartHashMismatchError, ConcurrencyConflictError, DuplicateRedemptionError, EvaluationCommitted, EvaluationCompleted, EvaluationNotFoundError, EvaluationRolledBack, GiftCardExhausted, GiftCardExhaustedError, GiftCardSpent, GiftCardToppedUp, InMemoryEvaluationStore, InsufficientBalanceError, InvalidTransitionError, MongoEvaluationStore, PendingEvaluationRepository, ProgramActivated, ProgramArchived, ProgramCreated, ProgramNotFoundError, ProgramPaused, ProgramRepository, ProgramUsageCapExceededError, PromoError, PromoEvents, RewardAdded, RewardNotFoundError, RewardRemoved, RewardRepository, RewardUpdated, RuleAdded, RuleNotFoundError, RuleRemoved, RuleRepository, RuleUpdated, TenantIsolationError, ValidationError, VoucherCancelled, VoucherExhaustedError, VoucherExpired, VoucherExpiredError, VoucherGenerated, VoucherNotFoundError, VoucherRedeemed, VoucherRepository, createPromoEngine, promoEventDefinitions, resolveConfig };