@classytic/party 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,1269 @@
1
+ import { Repository, isDuplicateKeyError, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
2
+ import { Schema } from "mongoose";
3
+ import { defineStateMachine } from "@classytic/primitives/state-machine";
4
+ import { parsePhone } from "@classytic/primitives/phone";
5
+ import { isOk } from "@classytic/primitives/result";
6
+ import { createEvent as createEvent$1, matchEventPattern } from "@classytic/primitives/events";
7
+ import { z } from "zod";
8
+ //#region src/domain/errors.ts
9
+ /** Base class so hosts can `instanceof PartyError` across every party throwable. */
10
+ var PartyError = class extends Error {
11
+ status;
12
+ code;
13
+ meta;
14
+ constructor(status, code, message, meta) {
15
+ super(message);
16
+ this.name = new.target.name;
17
+ this.status = status;
18
+ this.code = code;
19
+ if (meta !== void 0) this.meta = meta;
20
+ }
21
+ };
22
+ /** A referenced party id does not exist (or is out of the caller's scope). */
23
+ var PartyNotFoundError = class extends PartyError {
24
+ constructor(id) {
25
+ super(404, "party.not_found", `Party "${id}" not found.`, { partyId: id });
26
+ }
27
+ };
28
+ /**
29
+ * A merge was attempted against an illegal target — self-merge, merging into an
30
+ * already-merged (redirected) party, or a cross-scope pair. Carries both ids.
31
+ */
32
+ var PartyMergeError = class extends PartyError {
33
+ constructor(message, sourceId, targetId) {
34
+ super(409, "party.merge_conflict", message, {
35
+ sourceId,
36
+ targetId
37
+ });
38
+ }
39
+ };
40
+ /**
41
+ * `resolveOrCreate` lost the create race and the winning row could not be
42
+ * re-read (should be transient — surfaced rather than swallowed).
43
+ */
44
+ var PartyResolveConflictError = class extends PartyError {
45
+ constructor(identityKey) {
46
+ super(409, "party.resolve_conflict", `Concurrent create for identity "${identityKey}" could not be reconciled.`, { identityKey });
47
+ }
48
+ };
49
+ /**
50
+ * Optimistic-concurrency guard failed — the party was modified by another
51
+ * writer between read and write. Callers re-fetch and retry.
52
+ */
53
+ var PartyVersionConflictError = class extends PartyError {
54
+ constructor(id, expected) {
55
+ super(409, "party.version_conflict", `Party "${id}" was modified concurrently (expected v${expected}).`, {
56
+ partyId: id,
57
+ expectedVersion: expected
58
+ });
59
+ }
60
+ };
61
+ /**
62
+ * A Mongoose model name this package registers is already on the connection.
63
+ * Default init throws this; `forceRecreate: true` opts into delete+re-register
64
+ * (test fixtures / hot reload only). PACKAGE_RULES §21.
65
+ */
66
+ var PartyModelCollisionError = class extends PartyError {
67
+ constructor(modelName) {
68
+ super(500, "party.model_collision", `Mongoose model "${modelName}" is already registered on this connection. Another @classytic/party engine or a stale hot-reload owns it. Pass forceRecreate: true (tests / hot reload only) or use a dedicated connection.`, { modelName });
69
+ }
70
+ };
71
+ /** Input failed a domain-verb precondition (before any DB write). */
72
+ var PartyValidationError = class extends PartyError {
73
+ constructor(message, meta) {
74
+ super(400, "party.validation", message, meta);
75
+ }
76
+ };
77
+ //#endregion
78
+ //#region src/domain/enums.ts
79
+ /**
80
+ * Party-native enumerations.
81
+ *
82
+ * These are identity-kernel concepts (kind, role, lifecycle) — they have no
83
+ * one-to-one primitive to subset, so they are declared here as the source of
84
+ * truth. Domain FACETS (finance terms, CRM stage, loyalty tier) live in their
85
+ * OWN packages keyed by `partyId`; they are deliberately NOT modelled here —
86
+ * that separation is the whole point of the SAP-style thin identity kernel
87
+ * (vs. Odoo's `res.partner` god-object).
88
+ */
89
+ /** What kind of real-world entity a party is. */
90
+ const PARTY_KINDS = [
91
+ "person",
92
+ "company",
93
+ "group"
94
+ ];
95
+ /**
96
+ * Roles a party plays. NON-exclusive — one party can be a customer AND a
97
+ * supplier AND a contact simultaneously (the exact de-duplication a shared
98
+ * identity buys you). Roles are additive flags, never separate entities.
99
+ */
100
+ const PARTY_ROLES = [
101
+ "customer",
102
+ "supplier",
103
+ "contact",
104
+ "employee",
105
+ "partner"
106
+ ];
107
+ /**
108
+ * Identity lifecycle. `merged` is terminal — the row survives as a redirect
109
+ * (`mergedInto`) so historical references (orders, invoices) still resolve.
110
+ */
111
+ const PARTY_STATUSES = [
112
+ "active",
113
+ "archived",
114
+ "merged"
115
+ ];
116
+ /** Channel kind for a contact point. Drives normalization + dedup keys. */
117
+ const CONTACT_POINT_TYPES = ["email", "phone"];
118
+ /**
119
+ * Address role. Mirrors Odoo's `type` (contact/invoice/delivery/other) but
120
+ * as a first-class enum. A party carries many; `isDefault` picks the fallback.
121
+ */
122
+ const ADDRESS_TYPES = [
123
+ "billing",
124
+ "shipping",
125
+ "contact",
126
+ "other"
127
+ ];
128
+ //#endregion
129
+ //#region src/models/tenant-field.ts
130
+ /**
131
+ * Per PACKAGE_RULES §9.2: never hardcode the tenant column's Mongoose type —
132
+ * the engine config drives both the schema and `multiTenantPlugin` so they
133
+ * stay in sync. Hosts with Better Auth pick `'objectId'` (default — enables
134
+ * `$lookup`, `.populate()`, QueryParser `?lookup=organizationId`); UUID/slug
135
+ * hosts pick `'string'`.
136
+ *
137
+ * NOTE on party scoping: identity is frequently COMPANY-WIDE (one customer is
138
+ * the same person in every branch). Such hosts wire `multiTenant: false` at the
139
+ * engine and this field is omitted entirely. When multi-tenant IS on (SaaS,
140
+ * true per-tenant isolation), this drives the column type.
141
+ */
142
+ /**
143
+ * Tenant-column schema definition, or `undefined` when the engine is
144
+ * company-wide (`multiTenant: false`) so the field is simply not added.
145
+ */
146
+ function tenantFieldDef(config) {
147
+ if (!config.multiTenant) return void 0;
148
+ return config.tenantFieldType === "objectId" ? {
149
+ type: Schema.Types.ObjectId,
150
+ required: true,
151
+ ref: "organization",
152
+ index: true
153
+ } : {
154
+ type: String,
155
+ required: true,
156
+ index: true
157
+ };
158
+ }
159
+ //#endregion
160
+ //#region src/models/party.model.ts
161
+ /**
162
+ * Party — the single canonical identity. ONE document per real-world
163
+ * person/company/group, carrying non-exclusive roles, contact points,
164
+ * addresses, tax identity, B2B hierarchy, and 1:many auth-user links.
165
+ *
166
+ * This is a THIN identity kernel (SAP Business-Partner shape). Domain FACETS —
167
+ * finance terms (AR/AP, credit), CRM funnel stage, loyalty tier — live in their
168
+ * OWN packages keyed by `partyId`. Nothing here knows about accounting or CRM.
169
+ * That is the deliberate difference from Odoo's `res.partner` god-object.
170
+ */
171
+ const personNameSchema = new Schema({
172
+ given: {
173
+ type: String,
174
+ required: true,
175
+ trim: true,
176
+ maxlength: 120
177
+ },
178
+ family: {
179
+ type: String,
180
+ default: "",
181
+ trim: true,
182
+ maxlength: 120
183
+ },
184
+ middle: {
185
+ type: String,
186
+ trim: true,
187
+ maxlength: 120
188
+ },
189
+ prefix: {
190
+ type: String,
191
+ trim: true,
192
+ maxlength: 32
193
+ },
194
+ suffix: {
195
+ type: String,
196
+ trim: true,
197
+ maxlength: 32
198
+ },
199
+ preferred: {
200
+ type: String,
201
+ trim: true,
202
+ maxlength: 120
203
+ }
204
+ }, { _id: false });
205
+ const contactPointSchema = new Schema({
206
+ type: {
207
+ type: String,
208
+ enum: CONTACT_POINT_TYPES,
209
+ required: true
210
+ },
211
+ value: {
212
+ type: String,
213
+ required: true,
214
+ trim: true,
215
+ maxlength: 320
216
+ },
217
+ key: {
218
+ type: String,
219
+ required: true
220
+ },
221
+ isPrimary: {
222
+ type: Boolean,
223
+ default: false
224
+ },
225
+ verified: {
226
+ type: Boolean,
227
+ default: false
228
+ }
229
+ }, { _id: false });
230
+ const addressSchema = new Schema({
231
+ type: {
232
+ type: String,
233
+ enum: ADDRESS_TYPES,
234
+ default: "contact"
235
+ },
236
+ isDefault: {
237
+ type: Boolean,
238
+ default: false
239
+ },
240
+ label: {
241
+ type: String,
242
+ trim: true,
243
+ maxlength: 120
244
+ },
245
+ name: {
246
+ type: String,
247
+ trim: true,
248
+ maxlength: 200
249
+ },
250
+ phone: {
251
+ type: String,
252
+ trim: true,
253
+ maxlength: 50
254
+ },
255
+ email: {
256
+ type: String,
257
+ trim: true,
258
+ lowercase: true,
259
+ maxlength: 320
260
+ },
261
+ line1: {
262
+ type: String,
263
+ required: true,
264
+ trim: true,
265
+ maxlength: 300
266
+ },
267
+ line2: {
268
+ type: String,
269
+ trim: true,
270
+ maxlength: 300
271
+ },
272
+ city: {
273
+ type: String,
274
+ required: true,
275
+ trim: true,
276
+ maxlength: 150
277
+ },
278
+ state: {
279
+ type: String,
280
+ trim: true,
281
+ maxlength: 150
282
+ },
283
+ postalCode: {
284
+ type: String,
285
+ trim: true,
286
+ maxlength: 40
287
+ },
288
+ country: {
289
+ type: String,
290
+ required: true,
291
+ trim: true,
292
+ maxlength: 2
293
+ }
294
+ }, { _id: false });
295
+ const taxIdentitySchema = new Schema({
296
+ vat: {
297
+ type: String,
298
+ trim: true,
299
+ maxlength: 64
300
+ },
301
+ bin: {
302
+ type: String,
303
+ trim: true,
304
+ maxlength: 64
305
+ },
306
+ fiscalPositionCode: {
307
+ type: String,
308
+ trim: true,
309
+ maxlength: 64
310
+ },
311
+ countryCode: {
312
+ type: String,
313
+ trim: true,
314
+ maxlength: 2
315
+ }
316
+ }, { _id: false });
317
+ const externalRefSchema$1 = new Schema({
318
+ sourceId: {
319
+ type: String,
320
+ required: true
321
+ },
322
+ sourceModel: {
323
+ type: String,
324
+ required: true
325
+ }
326
+ }, { _id: false });
327
+ function buildPartySchema(config) {
328
+ const tenantField = tenantFieldDef(config);
329
+ const schema = new Schema({
330
+ ...tenantField ? { organizationId: tenantField } : {},
331
+ kind: {
332
+ type: String,
333
+ enum: PARTY_KINDS,
334
+ required: true,
335
+ default: "person"
336
+ },
337
+ displayName: {
338
+ type: String,
339
+ required: true,
340
+ trim: true,
341
+ maxlength: 300
342
+ },
343
+ legalName: {
344
+ type: String,
345
+ trim: true,
346
+ maxlength: 300
347
+ },
348
+ personName: { type: personNameSchema },
349
+ contactPoints: {
350
+ type: [contactPointSchema],
351
+ default: []
352
+ },
353
+ identityKeys: {
354
+ type: [String],
355
+ default: []
356
+ },
357
+ addresses: {
358
+ type: [addressSchema],
359
+ default: []
360
+ },
361
+ taxIdentity: { type: taxIdentitySchema },
362
+ roles: {
363
+ type: [String],
364
+ enum: PARTY_ROLES,
365
+ default: []
366
+ },
367
+ externalRefs: {
368
+ type: [externalRefSchema$1],
369
+ default: []
370
+ },
371
+ userLinks: {
372
+ type: [String],
373
+ default: []
374
+ },
375
+ parentId: {
376
+ type: Schema.Types.ObjectId,
377
+ ref: "Party"
378
+ },
379
+ status: {
380
+ type: String,
381
+ enum: PARTY_STATUSES,
382
+ default: "active",
383
+ index: true
384
+ },
385
+ mergedInto: {
386
+ type: Schema.Types.ObjectId,
387
+ ref: "Party"
388
+ },
389
+ tags: {
390
+ type: [String],
391
+ default: []
392
+ },
393
+ metadata: { type: Schema.Types.Mixed },
394
+ version: {
395
+ type: Number,
396
+ default: 0
397
+ }
398
+ }, {
399
+ timestamps: true,
400
+ toJSON: { virtuals: true },
401
+ toObject: { virtuals: true }
402
+ });
403
+ const scoped = (fields) => config.multiTenant ? {
404
+ organizationId: 1,
405
+ ...fields
406
+ } : fields;
407
+ schema.index(scoped({ identityKeys: 1 }), { unique: true });
408
+ schema.index(scoped({
409
+ roles: 1,
410
+ status: 1
411
+ }));
412
+ schema.index(scoped({ createdAt: -1 }));
413
+ schema.index(scoped({ parentId: 1 }));
414
+ schema.index(scoped({ userLinks: 1 }));
415
+ schema.index({
416
+ "externalRefs.sourceModel": 1,
417
+ "externalRefs.sourceId": 1
418
+ });
419
+ schema.virtual("primaryEmail").get(function() {
420
+ const cps = this.contactPoints ?? [];
421
+ return (cps.find((c) => c.type === "email" && c.isPrimary) ?? cps.find((c) => c.type === "email"))?.value;
422
+ });
423
+ schema.virtual("primaryPhone").get(function() {
424
+ const cps = this.contactPoints ?? [];
425
+ return (cps.find((c) => c.type === "phone" && c.isPrimary) ?? cps.find((c) => c.type === "phone"))?.value;
426
+ });
427
+ return schema;
428
+ }
429
+ //#endregion
430
+ //#region src/models/create-models.ts
431
+ /** Mongoose model names this package registers — drives the §21 collision check. */
432
+ const PARTY_MODEL_NAMES = ["Party"];
433
+ function createPartyModels(connection, config) {
434
+ const forceRecreate = config.forceRecreate ?? false;
435
+ const autoIndex = config.autoIndex ?? true;
436
+ if (forceRecreate) {
437
+ for (const name of PARTY_MODEL_NAMES) if (connection.models[name]) connection.deleteModel(name);
438
+ } else for (const name of PARTY_MODEL_NAMES) if (connection.models[name]) throw new PartyModelCollisionError(name);
439
+ const register = (name, schema) => {
440
+ schema.set("autoIndex", autoIndex);
441
+ return connection.model(name, schema);
442
+ };
443
+ return Object.freeze({ Party: register("Party", buildPartySchema(config)) });
444
+ }
445
+ //#endregion
446
+ //#region src/domain/state-machine.ts
447
+ /**
448
+ * Party identity lifecycle FSM — the compile-time twin of the `repo.claim()`
449
+ * CAS the repository uses for status transitions (PACKAGE_RULES §Concurrency).
450
+ *
451
+ * active ──archive──▶ archived ──restore──▶ active
452
+ * active ──merge────▶ merged (terminal; row survives as a `mergedInto` redirect)
453
+ *
454
+ * `merged` is terminal: a merged party is never resurrected — historical
455
+ * references resolve THROUGH it to the survivor, so the identity graph stays
456
+ * append-only and auditable.
457
+ */
458
+ const PARTY_STATE_MACHINE = defineStateMachine({
459
+ name: "party",
460
+ transitions: {
461
+ active: ["archived", "merged"],
462
+ archived: ["active", "merged"],
463
+ merged: []
464
+ }
465
+ });
466
+ //#endregion
467
+ //#region src/domain/normalize.ts
468
+ /**
469
+ * Identity-key normalization — pure, unit-tested. Derives the `identityKeys[]`
470
+ * an entity is deduped on: the SAME real-world email/phone always yields the
471
+ * SAME key regardless of casing/spacing/punctuation, so `resolveOrCreate` finds
472
+ * the existing party instead of forking a duplicate.
473
+ *
474
+ * Phone normalization DELEGATES to `@classytic/primitives/phone` (the canonical
475
+ * E.164 parser) — we never re-implement phone parsing (PACKAGE_RULES §P12).
476
+ * Email normalization is a conservative trim+lowercase: aggressive canonical-
477
+ * ization (gmail dot/plus stripping) is WRONG for identity — `a+shop@x.com` is
478
+ * a real, distinct inbox.
479
+ */
480
+ /** Lowercase + trim an email. Conservative — never strips dots/plus tags. */
481
+ function normalizeEmail(raw) {
482
+ return raw.trim().toLowerCase();
483
+ }
484
+ /**
485
+ * Canonical phone form for dedup. Uses primitives' `parsePhone`: a valid E.164
486
+ * input collapses to its canonical `+<cc><national>`; anything the strict parser
487
+ * rejects (local formats without a country code) falls back to a digit-only
488
+ * key so at least same-format duplicates still collapse. Hosts wanting global
489
+ * dedup pass already-E.164 numbers.
490
+ */
491
+ function normalizePhone(raw) {
492
+ const parsed = parsePhone(raw.trim());
493
+ if (isOk(parsed)) return parsed.value.e164;
494
+ const digits = raw.replace(/\D/g, "");
495
+ return digits ? `+${digits}` : "";
496
+ }
497
+ /** The stable dedup key for a contact point (`email:ada@x.com`, `phone:+8801...`). */
498
+ function contactPointKey(type, value) {
499
+ return `${type}:${type === "email" ? normalizeEmail(value) : normalizePhone(value)}`;
500
+ }
501
+ /** A tax-identity dedup key (`vat:...` / `bin:...`) — cross-scope-stable statutory id. */
502
+ function taxIdentityKey(kind, value) {
503
+ return `${kind}:${value.trim().toUpperCase().replace(/\s+/g, "")}`;
504
+ }
505
+ //#endregion
506
+ //#region src/domain/identity.ts
507
+ /**
508
+ * Identity derivation — PURE functions that turn a `ResolvePartyInput` into the
509
+ * contact points, dedup keys, and display name the repository persists. Kept
510
+ * out of the repository so they are trivially unit-testable in isolation (no
511
+ * Mongo) and the repository stays pure orchestration.
512
+ */
513
+ /** De-duplicate an array preserving first-seen order. */
514
+ function dedupe(arr) {
515
+ return [...new Set(arr)];
516
+ }
517
+ /**
518
+ * Turn the input's `emails[]` / `phones[]` into normalized `ContactPoint`s. The
519
+ * first email and first phone are marked primary; blanks are skipped.
520
+ */
521
+ function buildContactPoints(input) {
522
+ const points = [];
523
+ (input.emails ?? []).forEach((value, i) => {
524
+ if (!value.trim()) return;
525
+ points.push({
526
+ type: "email",
527
+ value,
528
+ key: contactPointKey("email", value),
529
+ isPrimary: i === 0,
530
+ verified: false
531
+ });
532
+ });
533
+ (input.phones ?? []).forEach((value, i) => {
534
+ if (!value.trim()) return;
535
+ points.push({
536
+ type: "phone",
537
+ value,
538
+ key: contactPointKey("phone", value),
539
+ isPrimary: i === 0,
540
+ verified: false
541
+ });
542
+ });
543
+ return points;
544
+ }
545
+ /**
546
+ * The de-dup keys a party is resolved on: normalized emails/phones, VAT/BIN
547
+ * statutory ids, and external-store refs. Blank/degenerate keys are dropped so
548
+ * they never poison the unique index.
549
+ */
550
+ function deriveIdentityKeys(contactPoints, input) {
551
+ const keys = contactPoints.map((c) => c.key);
552
+ if (input.taxIdentity?.vat) keys.push(taxIdentityKey("vat", input.taxIdentity.vat));
553
+ if (input.taxIdentity?.bin) keys.push(taxIdentityKey("bin", input.taxIdentity.bin));
554
+ for (const ref of input.externalRefs ?? []) keys.push(`ref:${ref.sourceModel}:${ref.sourceId}`);
555
+ return dedupe(keys.filter((k) => k && !k.endsWith(":")));
556
+ }
557
+ /** Best display name from the available signals — never empty. */
558
+ function resolveDisplayName(input, contactPoints) {
559
+ if (input.displayName?.trim()) return input.displayName.trim();
560
+ if (input.personName) {
561
+ const { given, family, preferred } = input.personName;
562
+ const composed = (preferred?.trim() || [given, family].filter(Boolean).join(" ")).trim();
563
+ if (composed) return composed;
564
+ }
565
+ if (input.legalName?.trim()) return input.legalName.trim();
566
+ const firstEmail = contactPoints.find((c) => c.type === "email");
567
+ if (firstEmail) return firstEmail.value;
568
+ const firstPhone = contactPoints.find((c) => c.type === "phone");
569
+ if (firstPhone) return firstPhone.value;
570
+ return "Unnamed party";
571
+ }
572
+ /** The primary (or first) contact-point value of a channel type. */
573
+ function pickPrimary(contactPoints, type) {
574
+ const cps = contactPoints ?? [];
575
+ return (cps.find((c) => c.type === type && c.isPrimary) ?? cps.find((c) => c.type === type))?.value;
576
+ }
577
+ //#endregion
578
+ //#region src/domain/merge.ts
579
+ /** Union two string arrays, first-seen order, no duplicates. */
580
+ function unionStrings(a, b) {
581
+ return [.../* @__PURE__ */ new Set([...a, ...b])];
582
+ }
583
+ /** Union external refs by `(sourceModel, sourceId)` identity. */
584
+ function unionRefs(a, b) {
585
+ const key = (r) => `${r.sourceModel}:${r.sourceId}`;
586
+ const seen = new Set(a.map(key));
587
+ const out = [...a];
588
+ for (const r of b) if (!seen.has(key(r))) {
589
+ seen.add(key(r));
590
+ out.push(r);
591
+ }
592
+ return out;
593
+ }
594
+ /**
595
+ * Union contact points by their normalized `key`. The survivor keeps its own
596
+ * primaries; absorbed points come in as non-primary (a merge must never create
597
+ * two primaries of the same channel).
598
+ */
599
+ function unionContacts(a, b) {
600
+ const seen = new Set(a.map((c) => c.key));
601
+ const out = [...a];
602
+ for (const c of b) if (!seen.has(c.key)) {
603
+ seen.add(c.key);
604
+ out.push({
605
+ ...c,
606
+ isPrimary: false
607
+ });
608
+ }
609
+ return out;
610
+ }
611
+ //#endregion
612
+ //#region src/events/event-constants.ts
613
+ /** PACKAGE_RULES §12: `package:resource.verb`. Emit ONLY these catalog names. */
614
+ const PARTY_EVENTS = {
615
+ CREATED: "party:party.created",
616
+ UPDATED: "party:party.updated",
617
+ ROLE_ADDED: "party:party.role_added",
618
+ ROLE_REMOVED: "party:party.role_removed",
619
+ USER_LINKED: "party:party.user_linked",
620
+ EXTERNAL_REF_ATTACHED: "party:party.external_ref_attached",
621
+ ARCHIVED: "party:party.archived",
622
+ RESTORED: "party:party.restored",
623
+ MERGED: "party:party.merged"
624
+ };
625
+ //#endregion
626
+ //#region src/events/helpers.ts
627
+ /**
628
+ * `createEvent` — thin wrapper over primitives' `createEvent` that stamps
629
+ * `source: 'party'` and lifts `actorId`/`organizationId`/`correlationId` off the
630
+ * `PartyContext`. Use this instead of importing `createEvent` directly so the
631
+ * source stamp + meta stay consistent (PACKAGE_RULES §14).
632
+ */
633
+ function createEvent(type, payload, ctx, meta) {
634
+ return createEvent$1(type, payload, {
635
+ source: "party",
636
+ ...ctx?.actorId !== void 0 ? { actorId: String(ctx.actorId) } : {},
637
+ ...ctx?.organizationId !== void 0 ? { organizationId: String(ctx.organizationId) } : {},
638
+ ...ctx?.correlationId !== void 0 ? { correlationId: ctx.correlationId } : {},
639
+ ...meta
640
+ });
641
+ }
642
+ //#endregion
643
+ //#region src/repositories/repo-options.ts
644
+ function repoOptionsFromCtx(ctx) {
645
+ return {
646
+ ...ctx.session !== void 0 ? { session: ctx.session } : {},
647
+ ...ctx.signal !== void 0 ? { signal: ctx.signal } : {},
648
+ ...ctx.retryPolicy !== void 0 ? { retryPolicy: ctx.retryPolicy } : {},
649
+ ...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
650
+ };
651
+ }
652
+ //#endregion
653
+ //#region src/repositories/party.repository.ts
654
+ /**
655
+ * PartyRepository — the API surface (PACKAGE_RULES §1) and the concrete
656
+ * `PartyPort`. Domain verbs only; CRUD/pagination/query come from mongokit.
657
+ *
658
+ * The identity guarantees:
659
+ * • `resolveOrCreate` is race-safe — a unique multikey index on `identityKeys`
660
+ * is the authoritative guard; a concurrent create that loses re-resolves to
661
+ * the winner (§Concurrency: no check-then-save, E11000 → re-read).
662
+ * • `merge` transfers the loser's identity keys to the survivor and turns the
663
+ * loser into a terminal `merged` redirect — historical refs still resolve.
664
+ */
665
+ const OBJECT_ID_HEX = /^[a-f0-9]{24}$/i;
666
+ var PartyRepository = class extends Repository {
667
+ events;
668
+ constructor(model, deps, plugins = []) {
669
+ super(model, plugins);
670
+ this.events = deps.events;
671
+ }
672
+ /** `update` that never returns null — the party was loaded first; a null means a concurrent delete. */
673
+ async mustUpdate(id, patch, ctx) {
674
+ const updated = await this.update(id, patch, repoOptionsFromCtx(ctx));
675
+ if (!updated) throw new PartyNotFoundError(String(id));
676
+ return updated;
677
+ }
678
+ async resolveOrCreate(input, ctx) {
679
+ const contactPoints = buildContactPoints(input);
680
+ const keys = deriveIdentityKeys(contactPoints, input);
681
+ if (keys.length === 0 && !input.displayName && !input.personName) throw new PartyValidationError("resolveOrCreate needs at least one identity signal (email/phone/vat/bin/externalRef) or a displayName.");
682
+ if (keys.length > 0) {
683
+ const existing = await this.getByQuery({
684
+ identityKeys: { $in: keys },
685
+ status: { $ne: "merged" }
686
+ }, {
687
+ ...repoOptionsFromCtx(ctx),
688
+ throwOnNotFound: false
689
+ });
690
+ if (existing) return {
691
+ party: await this.enrich(existing, input, ctx),
692
+ created: false
693
+ };
694
+ }
695
+ const displayName = resolveDisplayName(input, contactPoints);
696
+ try {
697
+ const created = await this.create({
698
+ kind: input.kind ?? (input.personName ? "person" : "company"),
699
+ displayName,
700
+ ...input.legalName !== void 0 ? { legalName: input.legalName } : {},
701
+ ...input.personName !== void 0 ? { personName: input.personName } : {},
702
+ contactPoints,
703
+ identityKeys: keys,
704
+ ...input.addresses !== void 0 ? { addresses: input.addresses } : {},
705
+ ...input.taxIdentity !== void 0 ? { taxIdentity: input.taxIdentity } : {},
706
+ roles: dedupe(input.roles ?? []),
707
+ externalRefs: input.externalRefs ?? [],
708
+ userLinks: input.userId ? [input.userId] : [],
709
+ ...input.parentId !== void 0 ? { parentId: input.parentId } : {},
710
+ ...input.metadata !== void 0 ? { metadata: input.metadata } : {}
711
+ }, repoOptionsFromCtx(ctx));
712
+ await this.dispatch(PARTY_EVENTS.CREATED, {
713
+ partyId: String(created._id),
714
+ kind: created.kind,
715
+ displayName: created.displayName,
716
+ roles: created.roles,
717
+ ...ctx.organizationId !== void 0 ? { organizationId: String(ctx.organizationId) } : {}
718
+ }, created, ctx);
719
+ return {
720
+ party: created,
721
+ created: true
722
+ };
723
+ } catch (err) {
724
+ if (isDuplicateKeyError(err) && keys.length > 0) {
725
+ const winner = await this.getByQuery({
726
+ identityKeys: { $in: keys },
727
+ status: { $ne: "merged" }
728
+ }, {
729
+ ...repoOptionsFromCtx(ctx),
730
+ throwOnNotFound: false
731
+ });
732
+ if (winner) return {
733
+ party: await this.enrich(winner, input, ctx),
734
+ created: false
735
+ };
736
+ throw new PartyResolveConflictError(keys[0] ?? "");
737
+ }
738
+ throw err;
739
+ }
740
+ }
741
+ async resolveMany(inputs, ctx) {
742
+ const results = [];
743
+ for (const input of inputs) results.push(await this.resolveOrCreate(input, ctx));
744
+ return results;
745
+ }
746
+ async snapshot(partyId, ctx) {
747
+ let party = await this.loadParty(partyId, ctx);
748
+ if (party.status === "merged" && party.mergedInto) party = await this.loadParty(String(party.mergedInto), ctx);
749
+ const primaryEmail = pickPrimary(party.contactPoints, "email");
750
+ const primaryPhone = pickPrimary(party.contactPoints, "phone");
751
+ return {
752
+ partyId: String(party._id),
753
+ kind: party.kind,
754
+ displayName: party.displayName,
755
+ ...party.legalName !== void 0 ? { legalName: party.legalName } : {},
756
+ ...primaryEmail !== void 0 ? { primaryEmail } : {},
757
+ ...primaryPhone !== void 0 ? { primaryPhone } : {},
758
+ roles: party.roles,
759
+ status: party.status,
760
+ ...party.taxIdentity !== void 0 ? { taxIdentity: party.taxIdentity } : {}
761
+ };
762
+ }
763
+ /**
764
+ * Sync identity onto a party — the canonical "identity changed" verb. The
765
+ * party OWNS identity mutation (name, contact points, addresses, tax), so
766
+ * hosts push a facet's current identity here instead of editing it locally.
767
+ * NEW contact points fold in (deduped, deriving their keys + extending
768
+ * `identityKeys`); existing primaries are preserved. Idempotent — syncing the
769
+ * same identity is a no-op. Emits `party:party.updated`.
770
+ */
771
+ async syncIdentity(id, input, ctx) {
772
+ const party = await this.loadParty(id, ctx);
773
+ const mergedContacts = unionContacts(party.contactPoints, buildContactPoints(input));
774
+ const set = {
775
+ contactPoints: mergedContacts,
776
+ identityKeys: unionStrings(party.identityKeys, deriveIdentityKeys(mergedContacts, input))
777
+ };
778
+ if (input.displayName?.trim()) set.displayName = input.displayName.trim();
779
+ if (input.legalName?.trim()) set.legalName = input.legalName.trim();
780
+ if (input.personName !== void 0) set.personName = input.personName;
781
+ if (input.addresses !== void 0) set.addresses = input.addresses;
782
+ if (input.taxIdentity !== void 0) set.taxIdentity = input.taxIdentity;
783
+ const updated = await this.mustUpdate(party._id, { $set: set }, ctx);
784
+ await this.dispatch(PARTY_EVENTS.UPDATED, {
785
+ partyId: String(party._id),
786
+ changed: Object.keys(set)
787
+ }, updated, ctx);
788
+ return updated;
789
+ }
790
+ async addRole(id, role, ctx) {
791
+ const party = await this.loadParty(id, ctx);
792
+ if (party.roles.includes(role)) return party;
793
+ const updated = await this.mustUpdate(party._id, { $addToSet: { roles: role } }, ctx);
794
+ await this.dispatch(PARTY_EVENTS.ROLE_ADDED, {
795
+ partyId: String(party._id),
796
+ role
797
+ }, updated, ctx);
798
+ return updated;
799
+ }
800
+ async removeRole(id, role, ctx) {
801
+ const party = await this.loadParty(id, ctx);
802
+ if (!party.roles.includes(role)) return party;
803
+ const updated = await this.mustUpdate(party._id, { $pull: { roles: role } }, ctx);
804
+ await this.dispatch(PARTY_EVENTS.ROLE_REMOVED, {
805
+ partyId: String(party._id),
806
+ role
807
+ }, updated, ctx);
808
+ return updated;
809
+ }
810
+ async linkUser(id, userId, ctx) {
811
+ const party = await this.loadParty(id, ctx);
812
+ if (party.userLinks.includes(userId)) return party;
813
+ const updated = await this.mustUpdate(party._id, { $addToSet: { userLinks: userId } }, ctx);
814
+ await this.dispatch(PARTY_EVENTS.USER_LINKED, {
815
+ partyId: String(party._id),
816
+ userId
817
+ }, updated, ctx);
818
+ return updated;
819
+ }
820
+ async attachExternalRef(id, ref, ctx) {
821
+ const party = await this.loadParty(id, ctx);
822
+ if (party.externalRefs.some((r) => r.sourceModel === ref.sourceModel && r.sourceId === ref.sourceId)) return party;
823
+ const updated = await this.mustUpdate(party._id, { $addToSet: { externalRefs: ref } }, ctx);
824
+ await this.dispatch(PARTY_EVENTS.EXTERNAL_REF_ATTACHED, {
825
+ partyId: String(party._id),
826
+ sourceModel: ref.sourceModel,
827
+ sourceId: ref.sourceId
828
+ }, updated, ctx);
829
+ return updated;
830
+ }
831
+ async archive(id, ctx) {
832
+ const party = await this.loadParty(id, ctx);
833
+ PARTY_STATE_MACHINE.assertTransition(String(party._id), party.status, "archived");
834
+ const claimed = await this.claim(String(party._id), {
835
+ from: "active",
836
+ to: "archived"
837
+ }, void 0, repoOptionsFromCtx(ctx));
838
+ if (!claimed) throw new PartyVersionConflictError(id, party.version);
839
+ await this.dispatch(PARTY_EVENTS.ARCHIVED, { partyId: String(party._id) }, claimed, ctx);
840
+ return claimed;
841
+ }
842
+ async restore(id, ctx) {
843
+ const party = await this.loadParty(id, ctx);
844
+ PARTY_STATE_MACHINE.assertTransition(String(party._id), party.status, "active");
845
+ const claimed = await this.claim(String(party._id), {
846
+ from: "archived",
847
+ to: "active"
848
+ }, void 0, repoOptionsFromCtx(ctx));
849
+ if (!claimed) throw new PartyVersionConflictError(id, party.version);
850
+ await this.dispatch(PARTY_EVENTS.RESTORED, { partyId: String(party._id) }, claimed, ctx);
851
+ return claimed;
852
+ }
853
+ /**
854
+ * Merge `sourceId` into `targetId`: the survivor absorbs the loser's identity
855
+ * keys, contact points, addresses, roles, external refs, and user links; the
856
+ * loser becomes a terminal `merged` redirect (`mergedInto`). Two-document
857
+ * write → runs in a transaction (falls back to sequential on standalone Mongo).
858
+ */
859
+ async merge(sourceId, targetId, ctx) {
860
+ if (sourceId === targetId) throw new PartyMergeError("Cannot merge a party into itself.", sourceId, targetId);
861
+ const source = await this.loadParty(sourceId, ctx);
862
+ const target = await this.loadParty(targetId, ctx);
863
+ if (source.status === "merged") throw new PartyMergeError("Source party is already merged.", sourceId, targetId);
864
+ if (target.status === "merged") throw new PartyMergeError("Target party is already merged (would create a redirect chain).", sourceId, targetId);
865
+ const mergedKeys = unionStrings(target.identityKeys, source.identityKeys);
866
+ const mergedRoles = unionStrings(target.roles, source.roles);
867
+ const mergedUsers = unionStrings(target.userLinks, source.userLinks);
868
+ const mergedRefs = unionRefs(target.externalRefs, source.externalRefs);
869
+ const mergedContacts = unionContacts(target.contactPoints, source.contactPoints);
870
+ const absorbed = mergedKeys.length - target.identityKeys.length;
871
+ await withTransaction(this.Model.db, async (session) => {
872
+ await this.update(source._id, { $set: {
873
+ status: "merged",
874
+ mergedInto: target._id,
875
+ identityKeys: []
876
+ } }, {
877
+ ...repoOptionsFromCtx(ctx),
878
+ session
879
+ });
880
+ await this.update(target._id, { $set: {
881
+ identityKeys: mergedKeys,
882
+ roles: mergedRoles,
883
+ userLinks: mergedUsers,
884
+ externalRefs: mergedRefs,
885
+ contactPoints: mergedContacts
886
+ } }, {
887
+ ...repoOptionsFromCtx(ctx),
888
+ session
889
+ });
890
+ }, { allowFallback: true });
891
+ const survivor = await this.loadParty(targetId, ctx);
892
+ await this.dispatch(PARTY_EVENTS.MERGED, {
893
+ sourceId,
894
+ targetId,
895
+ absorbedKeys: Math.max(0, absorbed)
896
+ }, survivor, ctx);
897
+ return survivor;
898
+ }
899
+ /** Load a party by ObjectId hex, or throw the typed not-found (§P9-style). */
900
+ async loadParty(id, ctx) {
901
+ const party = await this.getById(id, {
902
+ ...repoOptionsFromCtx(ctx),
903
+ throwOnNotFound: false,
904
+ ...OBJECT_ID_HEX.test(id) ? { idField: "_id" } : {}
905
+ });
906
+ if (!party) throw new PartyNotFoundError(id);
907
+ return party;
908
+ }
909
+ /** Add any new roles/refs/user/keys from `input` onto an already-resolved party. */
910
+ async enrich(party, input, ctx) {
911
+ const add = {};
912
+ const newRoles = (input.roles ?? []).filter((r) => !party.roles.includes(r));
913
+ if (newRoles.length > 0) add.roles = { $each: newRoles };
914
+ if (input.userId && !party.userLinks.includes(input.userId)) add.userLinks = input.userId;
915
+ const newRefs = (input.externalRefs ?? []).filter((r) => !party.externalRefs.some((e) => e.sourceModel === r.sourceModel && e.sourceId === r.sourceId));
916
+ if (newRefs.length > 0) add.externalRefs = { $each: newRefs };
917
+ if (Object.keys(add).length === 0) return party;
918
+ const updated = await this.mustUpdate(party._id, { $addToSet: add }, ctx);
919
+ for (const role of newRoles) await this.dispatch(PARTY_EVENTS.ROLE_ADDED, {
920
+ partyId: String(party._id),
921
+ role
922
+ }, updated, ctx);
923
+ return updated;
924
+ }
925
+ /** Route a domain event with meta stamped from ctx + the party's org. */
926
+ async dispatch(type, payload, party, ctx) {
927
+ await this.events.dispatch(createEvent(type, payload, ctx, {
928
+ resource: "party",
929
+ resourceId: String(party._id)
930
+ }), ctx);
931
+ }
932
+ };
933
+ //#endregion
934
+ //#region src/events/dispatch.ts
935
+ /** Context key carrying a post-commit publish queue (attached by `withTransaction`). */
936
+ const PENDING_EVENTS = Symbol.for("@classytic/party:pendingEvents");
937
+ var PartyEventDispatcher = class {
938
+ name;
939
+ transport;
940
+ outbox;
941
+ logger;
942
+ constructor(opts) {
943
+ this.transport = opts.transport;
944
+ this.outbox = opts.outbox;
945
+ this.logger = opts.logger;
946
+ this.name = opts.transport.name;
947
+ }
948
+ /** Raw publish — hosts and the unit-of-work flush use this. */
949
+ publish(event) {
950
+ return this.transport.publish(event);
951
+ }
952
+ subscribe(pattern, handler) {
953
+ return this.transport.subscribe(pattern, handler);
954
+ }
955
+ /**
956
+ * Route a domain event with full §P8 discipline. Reads the pending queue and
957
+ * session off `ctx`.
958
+ */
959
+ async dispatch(event, ctx) {
960
+ if (this.outbox) try {
961
+ await this.outbox.save(event, ctx.session !== void 0 ? { session: ctx.session } : {});
962
+ } catch (err) {
963
+ this.logger?.error?.(`[party] outbox.save failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
964
+ throw err;
965
+ }
966
+ const queue = ctx[PENDING_EVENTS];
967
+ if (Array.isArray(queue)) {
968
+ queue.push(event);
969
+ return;
970
+ }
971
+ try {
972
+ await this.transport.publish(event);
973
+ } catch (err) {
974
+ this.logger?.error?.(`[party] publish failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
975
+ }
976
+ }
977
+ async close() {
978
+ await this.transport.close?.();
979
+ }
980
+ };
981
+ /**
982
+ * Drain a pending-events queue to the transport AFTER the owning transaction
983
+ * commits. Per-event failures are swallowed — durable delivery is the outbox's
984
+ * job (PACKAGE_RULES §P8.1).
985
+ */
986
+ async function flushPending(dispatcher, queue, logger) {
987
+ for (const event of queue) try {
988
+ await dispatcher.publish(event);
989
+ } catch (err) {
990
+ logger?.error?.(`[party] post-commit publish failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
991
+ }
992
+ }
993
+ //#endregion
994
+ //#region src/events/in-process-bus.ts
995
+ /**
996
+ * In-process event bus — default fallback when no `eventTransport` is given to
997
+ * `createParty`. Structurally identical to arc's `MemoryEventTransport`;
998
+ * pattern-matching delegates to primitives' `matchEventPattern` (PACKAGE_RULES
999
+ * §13). Handler errors are swallowed-and-logged — emission never blocks writes.
1000
+ */
1001
+ var InProcessPartyBus = class {
1002
+ name = "in-process-party";
1003
+ subs = [];
1004
+ logger;
1005
+ constructor(options) {
1006
+ this.logger = options?.logger;
1007
+ }
1008
+ async publish(event) {
1009
+ const matching = this.subs.filter((s) => matchEventPattern(s.pattern, event.type));
1010
+ for (const sub of matching) try {
1011
+ await sub.handler(event);
1012
+ } catch (err) {
1013
+ this.logger?.error?.(`[party] event handler error [${event.type}]`, { error: err instanceof Error ? err.message : String(err) });
1014
+ }
1015
+ }
1016
+ async subscribe(pattern, handler) {
1017
+ const sub = {
1018
+ pattern,
1019
+ handler
1020
+ };
1021
+ this.subs.push(sub);
1022
+ return () => {
1023
+ this.subs = this.subs.filter((s) => s !== sub);
1024
+ };
1025
+ }
1026
+ async close() {
1027
+ this.subs = [];
1028
+ }
1029
+ };
1030
+ //#endregion
1031
+ //#region src/engine/ensure-ready.ts
1032
+ function assertPartyCapabilities(repo, opts = {}) {
1033
+ const caps = repo.capabilities;
1034
+ if (!caps) throw new Error("[party] backend declares no `capabilities` descriptor (needs repo-core 0.6 / mongokit 3.16+).");
1035
+ const missing = ["duplicateKeyError"].filter((flag) => caps[flag] !== true);
1036
+ if (missing.length > 0) throw new Error(`[party] backend missing required capabilities: ${missing.join(", ")}. resolveOrCreate relies on unique-index conflict detection for race-safe identity dedup.`);
1037
+ if (caps.transactions !== true) {
1038
+ const msg = "[party] backend does not support transactions — `merge` writes two documents and needs a replica set for atomicity.";
1039
+ if (opts.allowNonTransactional) opts.logger?.warn?.(`${msg} allowNonTransactional=true: merge falls back to sequential writes (partial-write risk).`);
1040
+ else throw new Error(`${msg} Run MongoDB as a replica set, or pass allowNonTransactional: true.`);
1041
+ }
1042
+ }
1043
+ //#endregion
1044
+ //#region src/engine/run-unit.ts
1045
+ /**
1046
+ * runUnit — transaction + post-commit-publish discipline (PACKAGE_RULES §P8.1),
1047
+ * identical in contract to crm / refit / review / cart / invoice.
1048
+ *
1049
+ * 1. Events dispatched inside the unit are QUEUED (`ctx[PENDING_EVENTS]`) and
1050
+ * flushed only AFTER commit (the queue resets on retry) — a rollback can't
1051
+ * leak ghost events to in-process subscribers. Outbox rows stay inside the
1052
+ * transaction; the relay reads only committed rows.
1053
+ * 2. A caller-owned `ctx.session` reuses the caller's queue when present.
1054
+ */
1055
+ async function runUnit(config, ctx, body) {
1056
+ const bag = ctx;
1057
+ const externalQueue = bag[PENDING_EVENTS];
1058
+ if (ctx.session) return body(externalQueue ? {
1059
+ ...bag,
1060
+ [PENDING_EVENTS]: externalQueue
1061
+ } : ctx);
1062
+ const pending = [];
1063
+ const result = await withTransaction(config.connection, async (session) => {
1064
+ pending.length = 0;
1065
+ return body({
1066
+ ...bag,
1067
+ session,
1068
+ [PENDING_EVENTS]: pending
1069
+ });
1070
+ }, config.allowNonTransactional ? { allowFallback: true } : {});
1071
+ await flushPending(config.events, pending, config.logger);
1072
+ return result;
1073
+ }
1074
+ //#endregion
1075
+ //#region src/engine/create-party.ts
1076
+ /**
1077
+ * createParty — engine factory for @classytic/party.
1078
+ *
1079
+ * 1. Validate `connection`.
1080
+ * 2. Resolve defaults (multiTenant=true, tenantFieldType=objectId, …).
1081
+ * 3. Create the Party model on the connection (collision policy §21).
1082
+ * 4. Resolve the EventTransport (or default in-process bus) + dispatcher.
1083
+ * 5. Build the repository — plugins composed in canonical order (§9.2).
1084
+ * 6. Boot-gate required capabilities, then freeze + return the engine.
1085
+ *
1086
+ * @example
1087
+ * ```ts
1088
+ * import mongoose from 'mongoose';
1089
+ * import { createParty } from '@classytic/party';
1090
+ *
1091
+ * const party = await createParty({ connection: mongoose.connection, multiTenant: false });
1092
+ *
1093
+ * const { party: p } = await party.port.resolveOrCreate(
1094
+ * { displayName: 'Ada Lovelace', emails: ['ada@x.com'], roles: ['customer'] },
1095
+ * {},
1096
+ * );
1097
+ * ```
1098
+ */
1099
+ async function createParty(config) {
1100
+ if (!config.connection) throw new Error("[party] PartyConfig.connection is required");
1101
+ const resolved = {
1102
+ connection: config.connection,
1103
+ multiTenant: config.multiTenant ?? true,
1104
+ tenantFieldType: config.tenantFieldType ?? "objectId",
1105
+ forceRecreate: config.forceRecreate ?? false,
1106
+ autoIndex: config.autoIndex ?? true,
1107
+ allowNonTransactional: config.allowNonTransactional ?? false,
1108
+ ...config.logger !== void 0 ? { logger: config.logger } : {},
1109
+ ...config.pagination !== void 0 ? { pagination: config.pagination } : {},
1110
+ plugins: config.plugins ?? []
1111
+ };
1112
+ const models = createPartyModels(config.connection, resolved);
1113
+ const events = new PartyEventDispatcher({
1114
+ transport: config.eventTransport ?? new InProcessPartyBus(config.logger ? { logger: config.logger } : void 0),
1115
+ ...config.outbox !== void 0 ? { outbox: config.outbox } : {},
1116
+ ...config.logger !== void 0 ? { logger: config.logger } : {}
1117
+ });
1118
+ const plugins = [...resolved.multiTenant ? [multiTenantPlugin({
1119
+ tenantField: "organizationId",
1120
+ fieldType: resolved.tenantFieldType,
1121
+ required: true
1122
+ })] : [], ...resolved.plugins];
1123
+ const repository = new PartyRepository(models.Party, {
1124
+ events,
1125
+ ...resolved.logger !== void 0 ? { logger: resolved.logger } : {}
1126
+ }, plugins);
1127
+ assertPartyCapabilities(repository, {
1128
+ allowNonTransactional: resolved.allowNonTransactional,
1129
+ ...resolved.logger?.warn !== void 0 ? { logger: { warn: (m) => resolved.logger?.warn?.(m) } } : {}
1130
+ });
1131
+ return Object.freeze({
1132
+ models,
1133
+ repositories: Object.freeze({ party: repository }),
1134
+ port: repository,
1135
+ events,
1136
+ config: Object.freeze(resolved),
1137
+ withTransaction(ctx, body) {
1138
+ return runUnit({
1139
+ connection: config.connection,
1140
+ events,
1141
+ ...resolved.logger !== void 0 ? { logger: resolved.logger } : {},
1142
+ allowNonTransactional: resolved.allowNonTransactional
1143
+ }, ctx, body);
1144
+ },
1145
+ async syncIndexes() {
1146
+ await Promise.all(Object.values(models).map((m) => m.syncIndexes()));
1147
+ },
1148
+ async dispose() {
1149
+ await events.close();
1150
+ }
1151
+ });
1152
+ }
1153
+ //#endregion
1154
+ //#region src/events/event-catalog.ts
1155
+ /**
1156
+ * Zod-source event catalog (PACKAGE_RULES §18). One Zod schema per event is the
1157
+ * single source of truth — runtime validation, static types, and arc's
1158
+ * `EventRegistry` (OpenAPI + `arc describe`) all derive from it. Structurally
1159
+ * identical to arc's `EventDefinitionOutput`; we never import arc.
1160
+ *
1161
+ * Timestamps use `z.iso.datetime()`, never `z.coerce.date()` (§25).
1162
+ */
1163
+ function definePartyEvent(input) {
1164
+ const { name, version = 1, description, zodSchema } = input;
1165
+ return {
1166
+ name,
1167
+ version,
1168
+ description,
1169
+ zodSchema,
1170
+ schema: z.toJSONSchema(zodSchema),
1171
+ create(payload, meta) {
1172
+ return createEvent$1(name, payload, {
1173
+ source: "party",
1174
+ ...meta
1175
+ });
1176
+ }
1177
+ };
1178
+ }
1179
+ const partyKind = z.enum([
1180
+ "person",
1181
+ "company",
1182
+ "group"
1183
+ ]);
1184
+ const partyRole = z.enum([
1185
+ "customer",
1186
+ "supplier",
1187
+ "contact",
1188
+ "employee",
1189
+ "partner"
1190
+ ]);
1191
+ const createdSchema = z.object({
1192
+ partyId: z.string(),
1193
+ kind: partyKind,
1194
+ displayName: z.string(),
1195
+ roles: z.array(partyRole),
1196
+ organizationId: z.string().optional()
1197
+ });
1198
+ const updatedSchema = z.object({
1199
+ partyId: z.string(),
1200
+ changed: z.array(z.string())
1201
+ });
1202
+ const roleChangedSchema = z.object({
1203
+ partyId: z.string(),
1204
+ role: partyRole
1205
+ });
1206
+ const userLinkedSchema = z.object({
1207
+ partyId: z.string(),
1208
+ userId: z.string()
1209
+ });
1210
+ const externalRefSchema = z.object({
1211
+ partyId: z.string(),
1212
+ sourceModel: z.string(),
1213
+ sourceId: z.string()
1214
+ });
1215
+ const lifecycleSchema = z.object({ partyId: z.string() });
1216
+ const mergedSchema = z.object({
1217
+ sourceId: z.string(),
1218
+ targetId: z.string(),
1219
+ absorbedKeys: z.number().int().nonnegative()
1220
+ });
1221
+ const partyEventDefinitions = [
1222
+ definePartyEvent({
1223
+ name: PARTY_EVENTS.CREATED,
1224
+ description: "A new canonical party identity was created.",
1225
+ zodSchema: createdSchema
1226
+ }),
1227
+ definePartyEvent({
1228
+ name: PARTY_EVENTS.UPDATED,
1229
+ description: "A party identity field changed.",
1230
+ zodSchema: updatedSchema
1231
+ }),
1232
+ definePartyEvent({
1233
+ name: PARTY_EVENTS.ROLE_ADDED,
1234
+ description: "A role (customer/supplier/…) was granted to a party.",
1235
+ zodSchema: roleChangedSchema
1236
+ }),
1237
+ definePartyEvent({
1238
+ name: PARTY_EVENTS.ROLE_REMOVED,
1239
+ description: "A role was revoked from a party.",
1240
+ zodSchema: roleChangedSchema
1241
+ }),
1242
+ definePartyEvent({
1243
+ name: PARTY_EVENTS.USER_LINKED,
1244
+ description: "An auth user was linked to a party (1:many).",
1245
+ zodSchema: userLinkedSchema
1246
+ }),
1247
+ definePartyEvent({
1248
+ name: PARTY_EVENTS.EXTERNAL_REF_ATTACHED,
1249
+ description: "An external-store reference was attached to a party (facet link).",
1250
+ zodSchema: externalRefSchema
1251
+ }),
1252
+ definePartyEvent({
1253
+ name: PARTY_EVENTS.ARCHIVED,
1254
+ description: "A party was archived.",
1255
+ zodSchema: lifecycleSchema
1256
+ }),
1257
+ definePartyEvent({
1258
+ name: PARTY_EVENTS.RESTORED,
1259
+ description: "An archived party was restored to active.",
1260
+ zodSchema: lifecycleSchema
1261
+ }),
1262
+ definePartyEvent({
1263
+ name: PARTY_EVENTS.MERGED,
1264
+ description: "A duplicate party was merged into a survivor; the source becomes a redirect.",
1265
+ zodSchema: mergedSchema
1266
+ })
1267
+ ];
1268
+ //#endregion
1269
+ export { ADDRESS_TYPES, CONTACT_POINT_TYPES, InProcessPartyBus, PARTY_EVENTS, PARTY_KINDS, PARTY_MODEL_NAMES, PARTY_ROLES, PARTY_STATE_MACHINE, PARTY_STATUSES, PENDING_EVENTS, PartyError, PartyEventDispatcher, PartyMergeError, PartyModelCollisionError, PartyNotFoundError, PartyRepository, PartyResolveConflictError, PartyValidationError, PartyVersionConflictError, buildContactPoints, buildPartySchema, contactPointKey, createEvent, createParty, createParty as default, createPartyModels, dedupe, deriveIdentityKeys, flushPending, normalizeEmail, normalizePhone, partyEventDefinitions, pickPrimary, repoOptionsFromCtx, resolveDisplayName, taxIdentityKey, tenantFieldDef, unionContacts, unionRefs, unionStrings };