mailery 0.9.0 → 0.10.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.
@@ -113,6 +113,21 @@ type Predicate = {
113
113
  };
114
114
  } | {
115
115
  fieldExists: string;
116
+ }
117
+ /**
118
+ * Tests a property of the event that STARTED this run, so the answer is
119
+ * per-run rather than per-contact. Use when the gate depends on what the run
120
+ * is about (which account, plan, order) instead of a durable trait of the
121
+ * person: a contact-level tag is shared by every concurrent run and the last
122
+ * writer wins, which silently changes branching in runs already in flight.
123
+ */
124
+ | {
125
+ triggerPropertyEquals: {
126
+ key: string;
127
+ value: string | number | boolean | null;
128
+ };
129
+ } | {
130
+ triggerPropertyTruthy: string;
116
131
  } | {
117
132
  hasFiredEvent: string;
118
133
  sinceFlowStart?: boolean;
@@ -1427,6 +1442,7 @@ declare class Mailer {
1427
1442
  */
1428
1443
  abortFlow(flowSlug: string, externalId: string, opts?: {
1429
1444
  reason?: string;
1445
+ matchTriggerProperties?: Record<string, unknown>;
1430
1446
  }): Promise<{
1431
1447
  abortedRuns: number;
1432
1448
  cancelledSends: number;
@@ -113,6 +113,21 @@ type Predicate = {
113
113
  };
114
114
  } | {
115
115
  fieldExists: string;
116
+ }
117
+ /**
118
+ * Tests a property of the event that STARTED this run, so the answer is
119
+ * per-run rather than per-contact. Use when the gate depends on what the run
120
+ * is about (which account, plan, order) instead of a durable trait of the
121
+ * person: a contact-level tag is shared by every concurrent run and the last
122
+ * writer wins, which silently changes branching in runs already in flight.
123
+ */
124
+ | {
125
+ triggerPropertyEquals: {
126
+ key: string;
127
+ value: string | number | boolean | null;
128
+ };
129
+ } | {
130
+ triggerPropertyTruthy: string;
116
131
  } | {
117
132
  hasFiredEvent: string;
118
133
  sinceFlowStart?: boolean;
@@ -1427,6 +1442,7 @@ declare class Mailer {
1427
1442
  */
1428
1443
  abortFlow(flowSlug: string, externalId: string, opts?: {
1429
1444
  reason?: string;
1445
+ matchTriggerProperties?: Record<string, unknown>;
1430
1446
  }): Promise<{
1431
1447
  abortedRuns: number;
1432
1448
  cancelledSends: number;
package/dist/testing.cjs CHANGED
@@ -14208,7 +14208,25 @@ var tagInputSchema = zod.z.object({
14208
14208
  var abortFlowInputSchema = zod.z.object({
14209
14209
  flowSlug: slugSchema,
14210
14210
  externalId: externalIdSchema,
14211
- reason: zod.z.string().min(1).max(200).optional()
14211
+ reason: zod.z.string().min(1).max(200).optional(),
14212
+ /**
14213
+ * Restrict the abort to runs whose trigger event carried these properties —
14214
+ * e.g. `{ accountId }` to cancel one account's series while the same
14215
+ * contact's other accounts keep running. Omit to abort every active run for
14216
+ * the contact on this flow.
14217
+ *
14218
+ * Keys and values are both constrained because these go straight into a
14219
+ * Mongo query. Values are primitives only: an object value like
14220
+ * `{ $ne: null }` would reach the query as an OPERATOR and match every
14221
+ * scoped run, turning a one-account abort into abort-everything. Hosts
14222
+ * typically pass an id from a request body, so treat it as untrusted. The
14223
+ * key regex likewise blocks `$`-prefixed keys and dots (a dot would silently
14224
+ * extend the path and change match semantics).
14225
+ */
14226
+ matchTriggerProperties: zod.z.record(
14227
+ zod.z.string().regex(/^[A-Za-z0-9_]+$/),
14228
+ zod.z.union([zod.z.string(), zod.z.number(), zod.z.boolean(), zod.z.null()])
14229
+ ).optional()
14212
14230
  });
14213
14231
  var abortAllFlowsInputSchema = abortFlowInputSchema.omit({ flowSlug: true });
14214
14232
  var sendOneOffInputSchema = zod.z.object({
@@ -14277,6 +14295,13 @@ var predicateSchema = zod.z.lazy(
14277
14295
  zod.z.object({ notHasTag: zod.z.string() }),
14278
14296
  zod.z.object({ fieldEquals: zod.z.object({ field: zod.z.string(), value: zod.z.unknown() }) }),
14279
14297
  zod.z.object({ fieldExists: zod.z.string() }),
14298
+ zod.z.object({
14299
+ triggerPropertyEquals: zod.z.object({
14300
+ key: zod.z.string().min(1),
14301
+ value: zod.z.union([zod.z.string(), zod.z.number(), zod.z.boolean(), zod.z.null()])
14302
+ })
14303
+ }),
14304
+ zod.z.object({ triggerPropertyTruthy: zod.z.string().min(1) }),
14280
14305
  zod.z.object({
14281
14306
  hasFiredEvent: zod.z.string(),
14282
14307
  sinceFlowStart: zod.z.boolean().optional(),
@@ -15367,6 +15392,12 @@ async function evaluatePredicate(predicate, ctx) {
15367
15392
  if ("fieldExists" in p) {
15368
15393
  return ctx.contact.fields[p.fieldExists] !== void 0;
15369
15394
  }
15395
+ if ("triggerPropertyEquals" in p) {
15396
+ return (ctx.run.triggerEvent?.properties ?? {})[p.triggerPropertyEquals.key] === p.triggerPropertyEquals.value;
15397
+ }
15398
+ if ("triggerPropertyTruthy" in p) {
15399
+ return Boolean((ctx.run.triggerEvent?.properties ?? {})[p.triggerPropertyTruthy]);
15400
+ }
15370
15401
  if ("subscriptionStatus" in p) {
15371
15402
  const sub = await ctx.collections.subscriptions.findOne({ externalId: ctx.contact.externalId });
15372
15403
  return sub?.status === p.subscriptionStatus;
@@ -15967,9 +15998,11 @@ function sha256(s) {
15967
15998
  }
15968
15999
 
15969
16000
  // src/server/runner/step.ts
16001
+ var DUE_SKEW_MS = 1e3;
15970
16002
  async function processOneRunStep(runId, ctx) {
15971
16003
  const run = await ctx.collections.flowRuns.findOne({ _id: runId });
15972
16004
  if (!run || run.status !== "active") return;
16005
+ if (run.nextActionAt && run.nextActionAt.getTime() > Date.now() + DUE_SKEW_MS) return;
15973
16006
  const flow = await ctx.collections.flows.findOne({ _id: run.flowId });
15974
16007
  if (!flow) {
15975
16008
  await failFlowRun(run, "flow_missing", ctx);
@@ -16113,7 +16146,14 @@ async function handleFireEvent(run, step2, ctx) {
16113
16146
  await ctx.collections.events.insertOne({
16114
16147
  externalId: run.externalId,
16115
16148
  name: step2.eventName,
16116
- properties: step2.properties ?? {},
16149
+ // Inherit the triggering event's properties so a handoff carries the
16150
+ // context that identifies what the run is ABOUT (which account, order,
16151
+ // subscription, ...). A step's `properties` are static — authored once in
16152
+ // the flow definition — so without this a fired event can only ever say
16153
+ // "this contact", losing the scope the originating event supplied, and
16154
+ // the receiving flow has nothing to resolve variables against. Explicit
16155
+ // step.properties win on conflict.
16156
+ properties: { ...run.triggerEvent?.properties ?? {}, ...step2.properties ?? {} },
16117
16157
  dedupeKey,
16118
16158
  occurredAt: /* @__PURE__ */ new Date(),
16119
16159
  createdAt: /* @__PURE__ */ new Date()
@@ -17676,14 +17716,25 @@ var Mailer = class _Mailer {
17676
17716
  * same handler that processes the business event ("user upgraded").
17677
17717
  */
17678
17718
  async abortFlow(flowSlug, externalId, opts = {}) {
17679
- const parsed = abortFlowInputSchema.parse({ flowSlug, externalId, reason: opts.reason });
17719
+ const parsed = abortFlowInputSchema.parse({
17720
+ flowSlug,
17721
+ externalId,
17722
+ reason: opts.reason,
17723
+ matchTriggerProperties: opts.matchTriggerProperties
17724
+ });
17680
17725
  const flow = await this.collections.flows.findOne(
17681
17726
  { slug: parsed.flowSlug },
17682
17727
  { projection: { _id: 1 } }
17683
17728
  );
17684
17729
  if (!flow) throw new Error(`abortFlow: unknown flow slug "${parsed.flowSlug}"`);
17730
+ const triggerMatch = Object.fromEntries(
17731
+ Object.entries(parsed.matchTriggerProperties ?? {}).map(([k, v]) => [
17732
+ `triggerEvent.properties.${k}`,
17733
+ v
17734
+ ])
17735
+ );
17685
17736
  const result = await this.abortActiveRuns(
17686
- { externalId: parsed.externalId, flowId: flow._id },
17737
+ { externalId: parsed.externalId, flowId: flow._id, ...triggerMatch },
17687
17738
  parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
17688
17739
  );
17689
17740
  if (result.abortedRuns > 0 || result.cancelledSends > 0) {
@@ -17691,7 +17742,9 @@ var Mailer = class _Mailer {
17691
17742
  actor: "host",
17692
17743
  action: "flow.abort",
17693
17744
  resource: { collection: "mailer_flow_runs", slug: parsed.flowSlug },
17694
- diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
17745
+ // Record the scope: without it a one-account abort and an abort-every-
17746
+ // run-for-this-contact are indistinguishable in the audit trail.
17747
+ diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} scope=${parsed.matchTriggerProperties ? JSON.stringify(parsed.matchTriggerProperties) : "all"} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
17695
17748
  });
17696
17749
  }
17697
17750
  return result;