mailery 0.5.1 → 0.7.0

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.
@@ -1,5 +1,5 @@
1
1
  import { Db } from 'mongodb';
2
- import { C as ContactAdapter, a as Contact, A as AdapterFilter, d as Mailer, s as NullProvider } from './null-BYB6v4Iv.cjs';
2
+ import { C as ContactAdapter, a as Contact, A as AdapterFilter, d as Mailer, s as NullProvider } from './null-B0rPgE5_.cjs';
3
3
  import 'zod';
4
4
  import 'handlebars';
5
5
  import 'ioredis';
package/dist/testing.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Db } from 'mongodb';
2
- import { C as ContactAdapter, a as Contact, A as AdapterFilter, d as Mailer, s as NullProvider } from './null-BYB6v4Iv.js';
2
+ import { C as ContactAdapter, a as Contact, A as AdapterFilter, d as Mailer, s as NullProvider } from './null-B0rPgE5_.js';
3
3
  import 'zod';
4
4
  import 'handlebars';
5
5
  import 'ioredis';
package/dist/testing.js CHANGED
@@ -1,7 +1,7 @@
1
+ import { z } from 'zod';
1
2
  import { MongoClient, ObjectId } from 'mongodb';
2
3
  import crypto2 from 'crypto';
3
4
  import sgMail from '@sendgrid/mail';
4
- import { z } from 'zod';
5
5
  import IORedis from 'ioredis';
6
6
  import Handlebars from 'handlebars';
7
7
  import { convert } from 'html-to-text';
@@ -50,6 +50,55 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
50
50
  ));
51
51
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
52
52
 
53
+ // src/server/adapters/vars.ts
54
+ var vars_exports = {};
55
+ __export(vars_exports, {
56
+ RESERVED_VAR_KEYS: () => RESERVED_VAR_KEYS,
57
+ assertNoReservedVarKeys: () => assertNoReservedVarKeys,
58
+ defineVars: () => defineVars,
59
+ resolveVars: () => resolveVars,
60
+ varsJsonSchema: () => varsJsonSchema
61
+ });
62
+ function defineVars(adapter) {
63
+ return adapter;
64
+ }
65
+ function assertNoReservedVarKeys(adapter) {
66
+ const json = varsJsonSchema(adapter);
67
+ const props = json && typeof json === "object" ? json.properties : void 0;
68
+ if (!props) return;
69
+ const clashes = RESERVED_VAR_KEYS.filter((k) => k in props);
70
+ if (clashes.length > 0) {
71
+ throw new Error(
72
+ `varsAdapter schema declares reserved key(s): ${clashes.join(", ")}. These names are provided by mailery itself \u2014 rename them in your schema.`
73
+ );
74
+ }
75
+ }
76
+ function varsJsonSchema(adapter) {
77
+ return z.toJSONSchema(adapter.schema, { io: "output" });
78
+ }
79
+ async function resolveVars(adapter, contact, info) {
80
+ if (!adapter) return {};
81
+ const resolved = await adapter.resolve(contact, info);
82
+ if (!resolved || typeof resolved !== "object") return {};
83
+ const out = { ...resolved };
84
+ for (const k of RESERVED_VAR_KEYS) delete out[k];
85
+ return out;
86
+ }
87
+ var RESERVED_VAR_KEYS;
88
+ var init_vars = __esm({
89
+ "src/server/adapters/vars.ts"() {
90
+ RESERVED_VAR_KEYS = [
91
+ "contact",
92
+ "vars",
93
+ "event",
94
+ "unsubscribeUrl",
95
+ "viewInBrowserUrl",
96
+ "preferenceCenterUrl",
97
+ "senderAddress"
98
+ ];
99
+ }
100
+ });
101
+
53
102
  // src/server/adapters/mongo.ts
54
103
  var mongo_exports = {};
55
104
  __export(mongo_exports, {
@@ -2992,19 +3041,19 @@ var require_range = __commonJS({
2992
3041
  var replaceCaret = (comp, options2) => {
2993
3042
  debug("caret", comp, options2);
2994
3043
  const r = options2.loose ? re[t.CARETLOOSE] : re[t.CARET];
2995
- const z2 = options2.includePrerelease ? "-0" : "";
3044
+ const z3 = options2.includePrerelease ? "-0" : "";
2996
3045
  return comp.replace(r, (_, M, m, p, pr) => {
2997
3046
  debug("caret", comp, _, M, m, p, pr);
2998
3047
  let ret;
2999
3048
  if (isX(M)) {
3000
3049
  ret = "";
3001
3050
  } else if (isX(m)) {
3002
- ret = `>=${M}.0.0${z2} <${+M + 1}.0.0-0`;
3051
+ ret = `>=${M}.0.0${z3} <${+M + 1}.0.0-0`;
3003
3052
  } else if (isX(p)) {
3004
3053
  if (M === "0") {
3005
- ret = `>=${M}.${m}.0${z2} <${M}.${+m + 1}.0-0`;
3054
+ ret = `>=${M}.${m}.0${z3} <${M}.${+m + 1}.0-0`;
3006
3055
  } else {
3007
- ret = `>=${M}.${m}.0${z2} <${+M + 1}.0.0-0`;
3056
+ ret = `>=${M}.${m}.0${z3} <${+M + 1}.0.0-0`;
3008
3057
  }
3009
3058
  } else if (pr) {
3010
3059
  debug("replaceCaret pr", pr);
@@ -3021,9 +3070,9 @@ var require_range = __commonJS({
3021
3070
  debug("no pr");
3022
3071
  if (M === "0") {
3023
3072
  if (m === "0") {
3024
- ret = `>=${M}.${m}.${p}${z2} <${M}.${m}.${+p + 1}-0`;
3073
+ ret = `>=${M}.${m}.${p}${z3} <${M}.${m}.${+p + 1}-0`;
3025
3074
  } else {
3026
- ret = `>=${M}.${m}.${p}${z2} <${M}.${+m + 1}.0-0`;
3075
+ ret = `>=${M}.${m}.${p}${z3} <${M}.${+m + 1}.0-0`;
3027
3076
  }
3028
3077
  } else {
3029
3078
  ret = `>=${M}.${m}.${p} <${+M + 1}.0.0-0`;
@@ -14143,6 +14192,12 @@ var tagInputSchema = z.object({
14143
14192
  externalId: externalIdSchema,
14144
14193
  tag: z.string().min(1).max(128)
14145
14194
  });
14195
+ var abortFlowInputSchema = z.object({
14196
+ flowSlug: slugSchema,
14197
+ externalId: externalIdSchema,
14198
+ reason: z.string().min(1).max(200).optional()
14199
+ });
14200
+ var abortAllFlowsInputSchema = abortFlowInputSchema.omit({ flowSlug: true });
14146
14201
  var sendOneOffInputSchema = z.object({
14147
14202
  templateSlug: slugSchema,
14148
14203
  externalId: externalIdSchema,
@@ -14172,7 +14227,13 @@ var flowStepSchema = z.lazy(
14172
14227
  type: z.literal("send"),
14173
14228
  templateSlug: slugSchema,
14174
14229
  providerOverride: z.string().optional(),
14175
- vars: z.record(z.string(), z.unknown()).optional()
14230
+ vars: z.record(z.string(), z.unknown()).optional(),
14231
+ delivery: z.object({
14232
+ weekdaysOnly: z.boolean().optional(),
14233
+ timeOfDay: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:mm").optional(),
14234
+ useContactTimezone: z.boolean().optional(),
14235
+ timezone: z.string().optional()
14236
+ }).optional()
14176
14237
  }),
14177
14238
  z.object({
14178
14239
  type: z.literal("tag"),
@@ -14907,6 +14968,7 @@ async function tryEnterFlow(flow, event, ctx) {
14907
14968
  flowSlug: flow.slug,
14908
14969
  flowVersion: flow.version,
14909
14970
  emailAtEntry: sub.emailAtSubscribe,
14971
+ triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
14910
14972
  enteredAt: /* @__PURE__ */ new Date(),
14911
14973
  status: "active",
14912
14974
  currentStepIndex: 0,
@@ -15004,6 +15066,105 @@ function effectiveLowerBound(ctx, opts) {
15004
15066
  }
15005
15067
  return null;
15006
15068
  }
15069
+
15070
+ // src/server/runner/delivery-window.ts
15071
+ var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
15072
+ function computeDeliveryTime(now, window2, contactTimezone) {
15073
+ const tz = pickTimezone(window2, contactTimezone);
15074
+ let candidate = now;
15075
+ if (window2.timeOfDay) {
15076
+ const [hh, mm] = window2.timeOfDay.split(":").map(Number);
15077
+ const local = localParts(candidate, tz);
15078
+ const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
15079
+ if (candidate.getTime() < todaySlot.getTime()) {
15080
+ candidate = todaySlot;
15081
+ } else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
15082
+ const next = addLocalDays(local, 1);
15083
+ candidate = utcFromLocal(next.y, next.mo, next.d, hh, mm, tz);
15084
+ }
15085
+ }
15086
+ if (window2.weekdaysOnly) {
15087
+ for (let guard = 0; guard < 3; guard++) {
15088
+ const local = localParts(candidate, tz);
15089
+ if (local.weekday !== "Sat" && local.weekday !== "Sun") break;
15090
+ const shift = local.weekday === "Sat" ? 2 : 1;
15091
+ const moved = addLocalDays(local, shift);
15092
+ candidate = utcFromLocal(moved.y, moved.mo, moved.d, local.hh, local.mi, tz);
15093
+ }
15094
+ }
15095
+ return candidate;
15096
+ }
15097
+ function pickTimezone(window2, contactTimezone) {
15098
+ const candidates = [
15099
+ window2.useContactTimezone ? contactTimezone : void 0,
15100
+ window2.timezone,
15101
+ "UTC"
15102
+ ];
15103
+ for (const tz of candidates) {
15104
+ if (tz && isValidTimezone(tz)) return tz;
15105
+ }
15106
+ return "UTC";
15107
+ }
15108
+ var validatedZones = /* @__PURE__ */ new Map();
15109
+ function isValidTimezone(tz) {
15110
+ const cached = validatedZones.get(tz);
15111
+ if (cached !== void 0) return cached;
15112
+ let ok = true;
15113
+ try {
15114
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
15115
+ } catch {
15116
+ ok = false;
15117
+ }
15118
+ validatedZones.set(tz, ok);
15119
+ return ok;
15120
+ }
15121
+ var partFormatters = /* @__PURE__ */ new Map();
15122
+ function formatterFor(tz) {
15123
+ let f = partFormatters.get(tz);
15124
+ if (!f) {
15125
+ f = new Intl.DateTimeFormat("en-US", {
15126
+ timeZone: tz,
15127
+ year: "numeric",
15128
+ month: "2-digit",
15129
+ day: "2-digit",
15130
+ hour: "2-digit",
15131
+ minute: "2-digit",
15132
+ second: "2-digit",
15133
+ weekday: "short",
15134
+ hour12: false
15135
+ });
15136
+ partFormatters.set(tz, f);
15137
+ }
15138
+ return f;
15139
+ }
15140
+ function localParts(date, tz) {
15141
+ const parts = {};
15142
+ for (const p of formatterFor(tz).formatToParts(date)) parts[p.type] = p.value;
15143
+ return {
15144
+ y: Number(parts.year),
15145
+ mo: Number(parts.month),
15146
+ d: Number(parts.day),
15147
+ hh: Number(parts.hour) % 24,
15148
+ // Intl emits '24' for midnight in some locales
15149
+ mi: Number(parts.minute),
15150
+ ss: Number(parts.second),
15151
+ weekday: parts.weekday
15152
+ };
15153
+ }
15154
+ function utcFromLocal(y, mo, d, hh, mi, tz) {
15155
+ let ts = Date.UTC(y, mo - 1, d, hh, mi, 0);
15156
+ for (let i = 0; i < 2; i++) {
15157
+ const p = localParts(new Date(ts), tz);
15158
+ const asUtc = Date.UTC(p.y, p.mo - 1, p.d, p.hh, p.mi, p.ss);
15159
+ const offset = asUtc - ts;
15160
+ ts = Date.UTC(y, mo - 1, d, hh, mi, 0) - offset;
15161
+ }
15162
+ return new Date(ts);
15163
+ }
15164
+ function addLocalDays(p, days) {
15165
+ const dt = new Date(Date.UTC(p.y, p.mo - 1, p.d + days));
15166
+ return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
15167
+ }
15007
15168
  async function compileTemplate(mjml) {
15008
15169
  const out = await mjml2html(mjml, { validationLevel: "soft", minify: false });
15009
15170
  const plainText = derivePlaintext(out.html);
@@ -15121,6 +15282,9 @@ function makeHandlebars(extra) {
15121
15282
  return hb;
15122
15283
  }
15123
15284
 
15285
+ // src/server/runner/send.ts
15286
+ init_vars();
15287
+
15124
15288
  // src/server/runner/suppression.ts
15125
15289
  var SCOPES_BY_KIND = {
15126
15290
  marketing: ["all", "marketing"],
@@ -15401,13 +15565,29 @@ async function dispatchSend(sendId, ctx) {
15401
15565
  return;
15402
15566
  }
15403
15567
  const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
15404
- const renderCtx = buildRenderContext(
15405
- contact,
15406
- run,
15407
- send.vars ?? {},
15408
- ctx
15409
- );
15410
- const rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
15568
+ if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
15569
+ await ctx.collections.sends.updateOne(
15570
+ { _id: send._id },
15571
+ { $set: { status: "cancelled", errorMessage: `cancelled: ${run.exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
15572
+ );
15573
+ return;
15574
+ }
15575
+ let renderCtx;
15576
+ let rendered;
15577
+ try {
15578
+ const resolved = await resolveVars(ctx.varsAdapter, contact, {
15579
+ reason: "send",
15580
+ templateSlug: template.slug,
15581
+ flowSlug: run?.flowSlug,
15582
+ eventName: run?.triggerEvent?.name,
15583
+ eventProperties: run?.triggerEvent?.properties
15584
+ });
15585
+ renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
15586
+ rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
15587
+ } catch (err) {
15588
+ await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
15589
+ throw err;
15590
+ }
15411
15591
  const tracking = applyTracking(rendered.html, {
15412
15592
  sendId: String(send._id),
15413
15593
  publicUrl: ctx.config.publicUrl,
@@ -15483,7 +15663,7 @@ function pickProviderName(stepOverride, tpl, ctx) {
15483
15663
  }
15484
15664
  return ctx.config.defaultProvider;
15485
15665
  }
15486
- function buildRenderContext(contact, run, vars, ctx) {
15666
+ function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
15487
15667
  const scope = "marketing";
15488
15668
  const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
15489
15669
  const token = signUnsubscribeToken(
@@ -15492,8 +15672,10 @@ function buildRenderContext(contact, run, vars, ctx) {
15492
15672
  );
15493
15673
  const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
15494
15674
  return {
15675
+ ...resolved,
15495
15676
  contact,
15496
15677
  vars,
15678
+ event: run?.triggerEvent?.properties ?? {},
15497
15679
  unsubscribeUrl,
15498
15680
  senderAddress: ctx.config.senderAddress
15499
15681
  };
@@ -15578,8 +15760,15 @@ async function processOneRunStep(runId, ctx) {
15578
15760
  return handleCondition(run, step, contact, ctx);
15579
15761
  case "branch":
15580
15762
  return handleBranch(run, step, contact, ctx);
15581
- case "send":
15763
+ case "send": {
15764
+ if (step.delivery) {
15765
+ const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step.delivery, contact.timezone);
15766
+ if (deliverAt.getTime() > Date.now() + 3e4) {
15767
+ return deferSendForWindow(run, deliverAt, ctx);
15768
+ }
15769
+ }
15582
15770
  return handleSend(run, step, contact, flow, ctx);
15771
+ }
15583
15772
  case "tag":
15584
15773
  return handleTag(run, step, ctx);
15585
15774
  case "fire_event":
@@ -15730,6 +15919,34 @@ async function handleWebhookStep(run, step, ctx) {
15730
15919
  }
15731
15920
  }
15732
15921
  }
15922
+ async function deferSendForWindow(run, deliverAt, ctx) {
15923
+ const updated = await ctx.collections.flowRuns.findOneAndUpdate(
15924
+ // Only write once per deferral — if nextActionAt already points at (or
15925
+ // past) the slot, another worker/tick got here first.
15926
+ { _id: run._id, currentStepIndex: run.currentStepIndex, nextActionAt: { $lt: deliverAt } },
15927
+ {
15928
+ $set: { nextActionAt: deliverAt, updatedAt: /* @__PURE__ */ new Date() },
15929
+ $push: {
15930
+ history: {
15931
+ stepIndex: run.currentStepIndex,
15932
+ action: "send_deferred",
15933
+ at: /* @__PURE__ */ new Date(),
15934
+ details: { until: deliverAt }
15935
+ }
15936
+ }
15937
+ },
15938
+ { returnDocument: "after" }
15939
+ );
15940
+ if (!updated) return;
15941
+ await ctx.queues.advance.add(
15942
+ "advance",
15943
+ { flowRunId: String(run._id) },
15944
+ {
15945
+ delay: Math.max(0, deliverAt.getTime() - Date.now()),
15946
+ jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
15947
+ }
15948
+ );
15949
+ }
15733
15950
  async function advanceStep(run, ctx, log, opts = {}) {
15734
15951
  const stepInc = opts.stepInc ?? 1;
15735
15952
  const updated = await ctx.collections.flowRuns.findOneAndUpdate(
@@ -16982,6 +17199,7 @@ var Mailer = class _Mailer {
16982
17199
  db: this.db,
16983
17200
  collections: this.collections,
16984
17201
  adapter: this.adapter,
17202
+ varsAdapter: this.config.varsAdapter,
16985
17203
  providers: this.providers,
16986
17204
  queues: this.queues,
16987
17205
  config: this.config,
@@ -17061,6 +17279,10 @@ var Mailer = class _Mailer {
17061
17279
  if (!config.providers[config.defaultProvider]) {
17062
17280
  throw new Error(`defaultProvider "${config.defaultProvider}" not in providers map`);
17063
17281
  }
17282
+ if (config.varsAdapter) {
17283
+ const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
17284
+ assertNoReservedVarKeys2(config.varsAdapter);
17285
+ }
17064
17286
  const collections = getCollections(config.db, config.collectionPrefix);
17065
17287
  await ensureIndexes(config.db, config.collectionPrefix);
17066
17288
  const queueDriver = await createQueueDriver(config.queue, config.db);
@@ -17276,6 +17498,72 @@ var Mailer = class _Mailer {
17276
17498
  await this.collections.contactTags.deleteOne({ externalId: parsed.externalId, tag: parsed.tag });
17277
17499
  }
17278
17500
  }
17501
+ // -------------------------------------------------------------------------
17502
+ // Flow abort
17503
+ // -------------------------------------------------------------------------
17504
+ /**
17505
+ * Abort every active run of one flow for a contact, immediately. Runs parked
17506
+ * in a `wait` exit too — their delayed wake-up jobs find the run exited and
17507
+ * no-op. Also cancels any of the flow's emails still sitting in the send
17508
+ * queue for this contact (queued or awaiting retry), so an abort means no
17509
+ * further mail, not just no further steps.
17510
+ *
17511
+ * No-op (returns zero counts) when nothing is active. Safe to call from the
17512
+ * same handler that processes the business event ("user upgraded").
17513
+ */
17514
+ async abortFlow(flowSlug, externalId, opts = {}) {
17515
+ const parsed = abortFlowInputSchema.parse({ flowSlug, externalId, reason: opts.reason });
17516
+ const flow = await this.collections.flows.findOne(
17517
+ { slug: parsed.flowSlug },
17518
+ { projection: { _id: 1 } }
17519
+ );
17520
+ if (!flow) throw new Error(`abortFlow: unknown flow slug "${parsed.flowSlug}"`);
17521
+ const result = await this.abortActiveRuns(
17522
+ { externalId: parsed.externalId, flowId: flow._id },
17523
+ parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
17524
+ );
17525
+ if (result.abortedRuns > 0 || result.cancelledSends > 0) {
17526
+ await this.audit({
17527
+ actor: "host",
17528
+ action: "flow.abort",
17529
+ resource: { collection: "mailer_flow_runs", slug: parsed.flowSlug },
17530
+ diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
17531
+ });
17532
+ }
17533
+ return result;
17534
+ }
17535
+ /**
17536
+ * Abort every active flow run for a contact across all flows. Same semantics
17537
+ * as `abortFlow` — for "stop everything" events (account deleted, churned).
17538
+ */
17539
+ async abortAllFlows(externalId, opts = {}) {
17540
+ const parsed = abortAllFlowsInputSchema.parse({ externalId, reason: opts.reason });
17541
+ const result = await this.abortActiveRuns(
17542
+ { externalId: parsed.externalId },
17543
+ parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
17544
+ );
17545
+ if (result.abortedRuns > 0 || result.cancelledSends > 0) {
17546
+ await this.audit({
17547
+ actor: "host",
17548
+ action: "flow.abort_all",
17549
+ resource: { collection: "mailer_flow_runs" },
17550
+ diffSummary: `abortAllFlows externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
17551
+ });
17552
+ }
17553
+ return result;
17554
+ }
17555
+ async abortActiveRuns(filter, exitReason) {
17556
+ const runs = await this.collections.flowRuns.find({ ...filter, status: "active" }).toArray();
17557
+ if (runs.length === 0) return { abortedRuns: 0, cancelledSends: 0 };
17558
+ for (const run of runs) {
17559
+ await exitFlowRun(run, exitReason, this.runnerContext);
17560
+ }
17561
+ const cancelled = await this.collections.sends.updateMany(
17562
+ { flowRunId: { $in: runs.map((r) => r._id) }, status: { $in: ["queued", "failed"] } },
17563
+ { $set: { status: "cancelled", errorMessage: `cancelled: ${exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
17564
+ );
17565
+ return { abortedRuns: runs.length, cancelledSends: cancelled.modifiedCount };
17566
+ }
17279
17567
  /**
17280
17568
  * GDPR right-to-erasure. Hard-deletes the contact's PII and leaves a hashed
17281
17569
  * suppression row to block re-import. INVARIANT 9.