@antelopejs/dms-mailing 0.5.0 → 0.6.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.
Files changed (106) hide show
  1. package/README.md +61 -339
  2. package/dist/data/templates-table.js +2 -10
  3. package/dist/data/templates-table.js.map +1 -1
  4. package/dist/db/models/index.d.ts +1 -0
  5. package/dist/db/models/index.js +1 -0
  6. package/dist/db/models/index.js.map +1 -1
  7. package/dist/db/models/send-events.model.d.ts +2 -0
  8. package/dist/db/models/send-events.model.js +11 -0
  9. package/dist/db/models/send-events.model.js.map +1 -1
  10. package/dist/db/models/sends.model.d.ts +3 -0
  11. package/dist/db/models/sends.model.js +13 -0
  12. package/dist/db/models/sends.model.js.map +1 -1
  13. package/dist/db/models/settings.model.d.ts +1 -0
  14. package/dist/db/models/suppressions.model.d.ts +1 -0
  15. package/dist/db/models/template-slugs.model.d.ts +27 -0
  16. package/dist/db/models/template-slugs.model.js +43 -0
  17. package/dist/db/models/template-slugs.model.js.map +1 -0
  18. package/dist/db/models/template-versions.model.d.ts +4 -1
  19. package/dist/db/models/template-versions.model.js +35 -2
  20. package/dist/db/models/template-versions.model.js.map +1 -1
  21. package/dist/db/models/templates.model.d.ts +2 -0
  22. package/dist/db/models/templates.model.js +22 -0
  23. package/dist/db/models/templates.model.js.map +1 -1
  24. package/dist/db/models/webhook-status.model.d.ts +1 -0
  25. package/dist/db/tables/index.d.ts +1 -0
  26. package/dist/db/tables/index.js +1 -0
  27. package/dist/db/tables/index.js.map +1 -1
  28. package/dist/db/tables/settings.table.d.ts +1 -0
  29. package/dist/db/tables/settings.table.js +4 -0
  30. package/dist/db/tables/settings.table.js.map +1 -1
  31. package/dist/db/tables/template-slugs.table.d.ts +7 -0
  32. package/dist/db/tables/template-slugs.table.js +35 -0
  33. package/dist/db/tables/template-slugs.table.js.map +1 -0
  34. package/dist/db/tables/templates.table.d.ts +5 -0
  35. package/dist/db/tables/templates.table.js +27 -0
  36. package/dist/db/tables/templates.table.js.map +1 -1
  37. package/dist/hooks/database-ready.d.ts +7 -0
  38. package/dist/hooks/database-ready.js +26 -0
  39. package/dist/hooks/database-ready.js.map +1 -0
  40. package/dist/hooks/index.d.ts +1 -0
  41. package/dist/hooks/index.js +1 -0
  42. package/dist/hooks/index.js.map +1 -1
  43. package/dist/implementations/dms-mailing/index.d.ts +3 -0
  44. package/dist/implementations/dms-mailing/index.js +7 -1
  45. package/dist/implementations/dms-mailing/index.js.map +1 -1
  46. package/dist/index.js +1 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/routes/templates.js +1 -0
  49. package/dist/routes/templates.js.map +1 -1
  50. package/dist/services/declaration-error.d.ts +6 -0
  51. package/dist/services/declaration-error.js +26 -0
  52. package/dist/services/declaration-error.js.map +1 -0
  53. package/dist/services/declarations.d.ts +3 -0
  54. package/dist/services/declarations.js +244 -0
  55. package/dist/services/declarations.js.map +1 -0
  56. package/dist/services/events.d.ts +4 -1
  57. package/dist/services/events.js.map +1 -1
  58. package/dist/services/fingerprint.d.ts +1 -0
  59. package/dist/services/fingerprint.js +20 -0
  60. package/dist/services/fingerprint.js.map +1 -0
  61. package/dist/services/send-import.d.ts +2 -0
  62. package/dist/services/send-import.js +136 -0
  63. package/dist/services/send-import.js.map +1 -0
  64. package/dist/services/templates.d.ts +3 -0
  65. package/dist/services/templates.js +75 -48
  66. package/dist/services/templates.js.map +1 -1
  67. package/dist/validation/declarations.schema.d.ts +299 -0
  68. package/dist/validation/declarations.schema.js +55 -0
  69. package/dist/validation/declarations.schema.js.map +1 -0
  70. package/frontend-vue/app/components/CategoriesInput.vue +12 -11
  71. package/frontend-vue/app/components/editor/EditorBlockList.vue +33 -30
  72. package/frontend-vue/app/components/editor/EditorPaletteGroup.vue +14 -14
  73. package/frontend-vue/app/composables/useSortableList.ts +159 -0
  74. package/frontend-vue/app/utils/blocks/dnd.ts +2 -0
  75. package/frontend-vue/i18n/locales/mailing-en-GB.json +1 -1
  76. package/frontend-vue/i18n/locales/mailing-fr-FR.json +1 -1
  77. package/frontend-vue/package.json +4 -3
  78. package/frontend-vue/pnpm-lock.yaml +6 -13
  79. package/frontend-vue/tests/editor-block-list.test.ts +46 -6
  80. package/frontend-vue/tests/sortable-list.test.ts +253 -0
  81. package/package.json +31 -14
  82. package/skills/dms-mailing/SKILL.md +101 -0
  83. package/src/data/templates-table.ts +7 -27
  84. package/src/db/models/index.ts +1 -0
  85. package/src/db/models/send-events.model.ts +13 -0
  86. package/src/db/models/sends.model.ts +22 -0
  87. package/src/db/models/template-slugs.model.ts +61 -0
  88. package/src/db/models/template-versions.model.ts +48 -2
  89. package/src/db/models/templates.model.ts +31 -0
  90. package/src/db/tables/index.ts +1 -0
  91. package/src/db/tables/settings.table.ts +8 -0
  92. package/src/db/tables/template-slugs.table.ts +29 -0
  93. package/src/db/tables/templates.table.ts +38 -0
  94. package/src/hooks/database-ready.ts +47 -0
  95. package/src/hooks/index.ts +1 -0
  96. package/src/implementations/dms-mailing/index.ts +17 -0
  97. package/src/index.ts +6 -1
  98. package/src/routes/templates.ts +2 -0
  99. package/src/services/declaration-error.ts +39 -0
  100. package/src/services/declarations.ts +450 -0
  101. package/src/services/events.ts +8 -2
  102. package/src/services/fingerprint.ts +22 -0
  103. package/src/services/send-import.ts +235 -0
  104. package/src/services/templates.ts +113 -72
  105. package/src/validation/declarations.schema.ts +77 -0
  106. package/frontend-vue/pnpm-workspace.yaml +0 -2
@@ -0,0 +1,450 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type {
3
+ DeclarationOutcome,
4
+ DeclareTemplateCategoryResult,
5
+ DeclareTemplateResult,
6
+ TemplateCategoryDeclaration,
7
+ TemplateDeclaration,
8
+ } from "@antelopejs/interface-dms-mailing";
9
+ import { Logging } from "@antelopejs/interface-core/logging";
10
+ import { GetModel } from "@antelopejs/interface-database-decorators";
11
+ import {
12
+ type MailingTemplate,
13
+ SettingsModel,
14
+ TemplateModel,
15
+ TemplateSlugModel,
16
+ TemplateVersionModel,
17
+ templateSlugIdOf,
18
+ } from "../db";
19
+ import type {
20
+ TemplateCategory,
21
+ TemplateContent,
22
+ VariableDefinition,
23
+ } from "../types";
24
+ import {
25
+ categoryDeclarationSchema,
26
+ type ParsedCategoryDeclaration,
27
+ type ParsedTemplateDeclaration,
28
+ templateDeclarationSchema,
29
+ } from "../validation/declarations.schema";
30
+ import { categoriesSchema } from "../validation/settings.schema";
31
+ import { DeclarationError, parseDeclared } from "./declaration-error";
32
+ import { fingerprintOf } from "./fingerprint";
33
+ import { findCategory, getSettings, saveSettings } from "./settings";
34
+ import {
35
+ assertSlugClaimed,
36
+ blankFields,
37
+ hasDraft,
38
+ localesWithoutSubject,
39
+ parseContent,
40
+ parseTestData,
41
+ parseVariables,
42
+ publishedVersionOf,
43
+ publishSnapshot,
44
+ requireTemplate,
45
+ workingContent,
46
+ workingVariables,
47
+ } from "./templates";
48
+
49
+ /** Who a declaration's writes are shown as by default. */
50
+ const DECLARATION_AUTHOR = "system";
51
+
52
+ const LIVE_STATUS = "live";
53
+ const LOG_PREFIX = "[dms-mailing]";
54
+ const MAX_DECLARATION_ATTEMPTS = 5;
55
+
56
+ /** What a declaration fingerprints: everything a user may change after it. */
57
+ interface DeclaredTemplateState {
58
+ name: string;
59
+ category: string;
60
+ content: TemplateContent;
61
+ variables: VariableDefinition[];
62
+ testData: Record<string, unknown>;
63
+ }
64
+
65
+ /** A declaration ready to write: validated, defaulted and fingerprinted. */
66
+ interface TemplatePlan {
67
+ tenantId: string;
68
+ slug: string;
69
+ state: DeclaredTemplateState;
70
+ author: string;
71
+ hash: string;
72
+ }
73
+
74
+ /**
75
+ * The values as the database gives them back: a `Date` in the test data is
76
+ * stored as its ISO string, and the fingerprint must compare alike.
77
+ */
78
+ const asStored = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
79
+
80
+ function assertPublishable(content: TemplateContent, slug: string): void {
81
+ if (!Object.keys(content.locales).length)
82
+ throw new DeclarationError(
83
+ "nothing_to_publish",
84
+ `Template "${slug}" declares no locale`,
85
+ );
86
+ const blank = localesWithoutSubject(content);
87
+ if (blank.length)
88
+ throw new DeclarationError(
89
+ "empty_subject",
90
+ `Template "${slug}" has no subject in: ${blank.join(", ")}`,
91
+ );
92
+ }
93
+
94
+ /** A category is left empty or one the workspace settings declare. */
95
+ function assertKnownCategory(
96
+ categories: TemplateCategory[],
97
+ category: string,
98
+ slug: string,
99
+ ): void {
100
+ if (!category || findCategory(categories, category)) return;
101
+ throw new DeclarationError(
102
+ "unknown_category",
103
+ `Template "${slug}" names category "${category}", which the workspace settings do not declare`,
104
+ );
105
+ }
106
+
107
+ function planTemplate(declaration: ParsedTemplateDeclaration): TemplatePlan {
108
+ const { tenantId } = declaration;
109
+ const { slug } = declaration;
110
+ const state = asStored<DeclaredTemplateState>({
111
+ name: declaration.name,
112
+ category: declaration.category,
113
+ content: declaration.content as TemplateContent,
114
+ variables: declaration.variables,
115
+ testData: declaration.testData,
116
+ });
117
+ assertPublishable(state.content, slug);
118
+ return {
119
+ tenantId,
120
+ slug,
121
+ state,
122
+ author: declaration.author ?? DECLARATION_AUTHOR,
123
+ hash: fingerprintOf(state),
124
+ };
125
+ }
126
+
127
+ /**
128
+ * The fingerprint of what the template holds now, `null` when a user took it
129
+ * out of the state a declaration leaves it in: live, with no draft waiting.
130
+ */
131
+ function currentFingerprint(template: MailingTemplate): string | null {
132
+ if (template.status !== LIVE_STATUS || hasDraft(template)) return null;
133
+ return fingerprintOf({
134
+ name: template.name,
135
+ category: template.category ?? "",
136
+ content: parseContent(template),
137
+ variables: parseVariables(template),
138
+ testData: parseTestData(template),
139
+ });
140
+ }
141
+
142
+ function resultOf(
143
+ template: MailingTemplate,
144
+ outcome: DeclarationOutcome,
145
+ version = publishedVersionOf(template),
146
+ ): DeclareTemplateResult {
147
+ return {
148
+ templateId: template._id,
149
+ slug: template.slug,
150
+ outcome,
151
+ version,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Inserts the template as a fresh draft of the declared content, claims its
157
+ * slug like the dashboard's creation does. The seed is not an applied
158
+ * declaration: any caller can finish its first publication after a crash.
159
+ */
160
+ async function createDeclared(
161
+ plan: TemplatePlan,
162
+ ): Promise<MailingTemplate | undefined> {
163
+ const { tenantId, slug, state, author } = plan;
164
+ const model = GetModel(TemplateModel, tenantId);
165
+ const settings = await getSettings(tenantId);
166
+ assertKnownCategory(settings.categories, state.category, slug);
167
+ const revision = randomUUID();
168
+ const [id] = await model.insert({
169
+ slug,
170
+ name: state.name,
171
+ category: state.category,
172
+ status: "draft",
173
+ ...blankFields(state.content),
174
+ json_variables: JSON.stringify(state.variables),
175
+ json_test_data: JSON.stringify(state.testData),
176
+ updatedBy: author,
177
+ revision,
178
+ declarationSeedRevision: revision,
179
+ pendingDeclaredHash: plan.hash,
180
+ });
181
+ const templateId = id as string;
182
+ try {
183
+ await assertSlugClaimed(tenantId, slug, templateId);
184
+ } catch (error) {
185
+ if (await model.getBySlug(slug)) return undefined;
186
+ throw error;
187
+ }
188
+ return requireTemplate(model, templateId);
189
+ }
190
+
191
+ /**
192
+ * A slug claim, not an arbitrary candidate row, decides which template won
193
+ * concurrent creation. An unclaimed seed can be claimed by its next caller.
194
+ */
195
+ async function declaredTemplate(
196
+ tenantId: string,
197
+ slug: string,
198
+ ): Promise<MailingTemplate | undefined> {
199
+ const model = GetModel(TemplateModel, tenantId);
200
+ const claim = await GetModel(TemplateSlugModel, tenantId).get(
201
+ templateSlugIdOf(tenantId, slug),
202
+ );
203
+ const winner = claim && (await model.get(claim.templateId));
204
+ return winner || model.getBySlug(slug);
205
+ }
206
+
207
+ function isUnmodifiedSeed(template: MailingTemplate): boolean {
208
+ return Boolean(
209
+ template.pendingDeclaredHash &&
210
+ template.declarationSeedRevision &&
211
+ template.revision === template.declarationSeedRevision &&
212
+ template.status === "draft" &&
213
+ template.publishedVersion === 0,
214
+ );
215
+ }
216
+
217
+ /** Finishes the stored seed, never substituting a later caller's declaration. */
218
+ async function finishSeed(
219
+ tenantId: string,
220
+ template: MailingTemplate,
221
+ ): Promise<boolean> {
222
+ try {
223
+ await assertSlugClaimed(tenantId, template.slug, template._id);
224
+ } catch (error) {
225
+ const winner = await declaredTemplate(tenantId, template.slug);
226
+ if (winner && winner._id !== template._id) return false;
227
+ throw error;
228
+ }
229
+ const settings = await getSettings(tenantId);
230
+ assertKnownCategory(settings.categories, template.category, template.slug);
231
+ return Boolean(
232
+ await publishSnapshot(
233
+ tenantId,
234
+ template,
235
+ {
236
+ content: workingContent(template),
237
+ variables: workingVariables(template),
238
+ },
239
+ template.updatedBy,
240
+ {
241
+ declaredHash: template.pendingDeclaredHash,
242
+ pendingDeclaredHash: "",
243
+ declarationSeedRevision: "",
244
+ },
245
+ ),
246
+ );
247
+ }
248
+
249
+ /** Why an existing template is left alone, `null` when it may be refreshed. */
250
+ function keepReason(template: MailingTemplate): string | null {
251
+ if (!template.declaredHash) return "it was created in the dashboard";
252
+ if (currentFingerprint(template) !== template.declaredHash)
253
+ return "a user changed it since it was declared";
254
+ return null;
255
+ }
256
+
257
+ async function applyToExisting(
258
+ plan: TemplatePlan,
259
+ template: MailingTemplate,
260
+ ): Promise<DeclareTemplateResult> {
261
+ const { tenantId, state, author } = plan;
262
+ for (let attempt = 0; attempt < MAX_DECLARATION_ATTEMPTS; attempt++) {
263
+ if (isUnmodifiedSeed(template)) {
264
+ const created = await finishSeed(tenantId, template);
265
+ if (created && template.pendingDeclaredHash === plan.hash)
266
+ return resultOf(template, "created", 1);
267
+ } else {
268
+ await GetModel(TemplateVersionModel, tenantId).materialize(template);
269
+ if (template.publishedVersion > 0 && template.declaredHash === plan.hash)
270
+ return resultOf(template, "unchanged");
271
+ const reason = keepReason(template);
272
+ if (reason) {
273
+ Logging.Info(
274
+ `${LOG_PREFIX} Kept template "${plan.slug}" as it is: ${reason}`,
275
+ );
276
+ return resultOf(template, "kept");
277
+ }
278
+ const settings = await getSettings(tenantId);
279
+ assertKnownCategory(settings.categories, state.category, plan.slug);
280
+ const outcome = await publishSnapshot(tenantId, template, state, author, {
281
+ name: state.name,
282
+ category: state.category,
283
+ json_test_data: JSON.stringify(state.testData),
284
+ declaredHash: plan.hash,
285
+ });
286
+ if (outcome) return resultOf(template, "updated", outcome.version);
287
+ }
288
+ const current = await declaredTemplate(tenantId, plan.slug);
289
+ if (!current) break;
290
+ template = current;
291
+ }
292
+ throw new DeclarationError(
293
+ "declaration_conflict",
294
+ `Template "${plan.slug}" changed repeatedly; retry the declaration`,
295
+ );
296
+ }
297
+
298
+ /**
299
+ * Creates or refreshes the template a project declares from code. The rule,
300
+ * keyed on the slug:
301
+ *
302
+ * - no template has the slug: it is created and published (`created`);
303
+ * - the declaration is the one last applied: nothing is written
304
+ * (`unchanged`), whatever a user did to the template since;
305
+ * - the declaration changed and the template is still live, with no draft,
306
+ * and holds exactly what the last declaration wrote (name, category,
307
+ * content, variables, test data): the new declaration is published
308
+ * (`updated`);
309
+ * - otherwise (edited, unpublished, archived, a draft pending, or made in the
310
+ * dashboard): it is left as it is (`kept`).
311
+ *
312
+ * A declared template a user deleted is created again by the next call.
313
+ */
314
+ export async function declareTemplate(
315
+ input: TemplateDeclaration,
316
+ ): Promise<DeclareTemplateResult> {
317
+ const declaration = parseDeclared(
318
+ templateDeclarationSchema,
319
+ input,
320
+ "invalid_template",
321
+ `template declaration "${String(input?.slug)}"`,
322
+ );
323
+ const plan = planTemplate(declaration);
324
+ const existing = await declaredTemplate(plan.tenantId, plan.slug);
325
+ if (existing) return applyToExisting(plan, existing);
326
+ const created = await createDeclared(plan);
327
+ if (created) return applyToExisting(plan, created);
328
+ const winner = (await declaredTemplate(
329
+ plan.tenantId,
330
+ plan.slug,
331
+ )) as MailingTemplate;
332
+ return applyToExisting(plan, winner);
333
+ }
334
+
335
+ type DeclaredCategories = Record<string, string>;
336
+
337
+ const categoryFingerprint = (category: Omit<TemplateCategory, "id">) =>
338
+ fingerprintOf({ label: category.label, icon: category.icon });
339
+
340
+ /** Category declarations of one tenant run one after the other. */
341
+ const categoryQueues = new Map<string, Promise<unknown>>();
342
+
343
+ /**
344
+ * Runs `task` after the tenant's previous category declaration: each one
345
+ * rewrites the whole category list, and a project declaring several at once
346
+ * must not lose any to a concurrent write.
347
+ */
348
+ function inTenantQueue<T>(
349
+ tenantId: string,
350
+ task: () => Promise<T>,
351
+ ): Promise<T> {
352
+ const previous = categoryQueues.get(tenantId) ?? Promise.resolve();
353
+ const next = previous.then(task, task);
354
+ categoryQueues.set(
355
+ tenantId,
356
+ next.catch(() => undefined),
357
+ );
358
+ return next;
359
+ }
360
+
361
+ async function readDeclaredCategories(
362
+ tenantId: string,
363
+ ): Promise<DeclaredCategories> {
364
+ const row = await GetModel(SettingsModel, tenantId).get(tenantId);
365
+ return JSON.parse(
366
+ row?.json_declared_categories || "{}",
367
+ ) as DeclaredCategories;
368
+ }
369
+
370
+ /** The category list once the declared category is added or relabelled. */
371
+ function withCategory(
372
+ categories: TemplateCategory[],
373
+ declared: TemplateCategory,
374
+ ): TemplateCategory[] {
375
+ const isPresent = Boolean(findCategory(categories, declared.id));
376
+ const next = isPresent
377
+ ? categories.map((category) =>
378
+ category.id === declared.id ? declared : category,
379
+ )
380
+ : [...categories, declared];
381
+ return parseDeclared(
382
+ categoriesSchema,
383
+ next,
384
+ "invalid_category",
385
+ `category declaration "${declared.id}"`,
386
+ ).map(({ id, label, icon }) => ({ id, label, icon }));
387
+ }
388
+
389
+ /** What a category declaration does to the stored list, by its state. */
390
+ function categoryOutcome(
391
+ current: TemplateCategory | undefined,
392
+ previousHash: string | undefined,
393
+ hash: string,
394
+ ): DeclarationOutcome {
395
+ if (!current) return previousHash ? "kept" : "created";
396
+ if (!previousHash) return "kept";
397
+ if (previousHash === hash) return "unchanged";
398
+ return categoryFingerprint(current) === previousHash ? "updated" : "kept";
399
+ }
400
+
401
+ const WRITING_OUTCOMES = new Set<DeclarationOutcome>(["created", "updated"]);
402
+
403
+ async function applyCategory(
404
+ declaration: ParsedCategoryDeclaration,
405
+ ): Promise<DeclareTemplateCategoryResult> {
406
+ const { tenantId } = declaration;
407
+ const category = {
408
+ id: declaration.id,
409
+ label: declaration.label,
410
+ icon: declaration.icon,
411
+ };
412
+ const hash = categoryFingerprint(category);
413
+ const settings = await getSettings(tenantId);
414
+ const declared = await readDeclaredCategories(tenantId);
415
+ const outcome = categoryOutcome(
416
+ findCategory(settings.categories, category.id),
417
+ declared[category.id],
418
+ hash,
419
+ );
420
+ if (!WRITING_OUTCOMES.has(outcome)) return { id: category.id, outcome };
421
+ const categories = withCategory(settings.categories, category);
422
+ await saveSettings(tenantId, { ...settings, categories });
423
+ await GetModel(SettingsModel, tenantId).update(tenantId, {
424
+ json_declared_categories: JSON.stringify({
425
+ ...declared,
426
+ [category.id]: hash,
427
+ }),
428
+ });
429
+ return { id: category.id, outcome };
430
+ }
431
+
432
+ /**
433
+ * Adds or refreshes a template category a project declares from code, keyed
434
+ * on its id: a new id is added (`created`); the declaration last applied is
435
+ * left alone (`unchanged`); a changed declaration relabels a category still
436
+ * as last declared (`updated`); a category a user relabelled or removed
437
+ * since, or one that existed before any declaration (the defaults among
438
+ * them), is left as it is (`kept`).
439
+ */
440
+ export function declareTemplateCategory(
441
+ input: TemplateCategoryDeclaration,
442
+ ): Promise<DeclareTemplateCategoryResult> {
443
+ const declaration = parseDeclared(
444
+ categoryDeclarationSchema,
445
+ input,
446
+ "invalid_category",
447
+ `category declaration "${String(input?.id)}"`,
448
+ );
449
+ return inTenantQueue(declaration.tenantId, () => applyCategory(declaration));
450
+ }
@@ -147,13 +147,19 @@ async function publishEmailEvent(
147
147
  const eventIdOf = (details: Record<string, unknown> | undefined): string =>
148
148
  textOf(details?.eventId);
149
149
 
150
+ type StoredEventIdentity = Pick<
151
+ MailingSendEvent,
152
+ "type" | "at" | "json_details"
153
+ >;
154
+ type IncomingEventIdentity = Pick<EmailEvent, "type" | "at" | "details">;
155
+
150
156
  /**
151
157
  * Whether a stored event is the one a webhook delivers again: the same
152
158
  * provider event id, or the same type, time and details when it has none.
153
159
  */
154
160
  export function isSameEvent(
155
- stored: MailingSendEvent,
156
- event: EmailEvent,
161
+ stored: StoredEventIdentity,
162
+ event: IncomingEventIdentity,
157
163
  ): boolean {
158
164
  const eventId = eventIdOf(event.details);
159
165
  if (eventId) {
@@ -0,0 +1,22 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /** JSON with object keys sorted, so equal values always serialize alike. */
4
+ function stableJson(value: unknown): string {
5
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
6
+ if (value && typeof value === "object") {
7
+ const entries = Object.entries(value as Record<string, unknown>)
8
+ .filter(([, entry]) => entry !== undefined)
9
+ .sort(([left], [right]) => Number(left > right) - Number(left < right))
10
+ .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`);
11
+ return `{${entries.join(",")}}`;
12
+ }
13
+ return JSON.stringify(value);
14
+ }
15
+
16
+ /**
17
+ * A short digest of `value` that ignores key order: what a declaration
18
+ * remembers of what it wrote, to tell later whether a user changed it.
19
+ */
20
+ export function fingerprintOf(value: unknown): string {
21
+ return createHash("sha256").update(stableJson(value)).digest("hex");
22
+ }