@classytic/party 0.1.0 → 0.2.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 CHANGED
@@ -1,80 +1,13 @@
1
- import { Repository, isDuplicateKeyError, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
1
+ import { Repository, defineModels, isDuplicateKeyError, multiTenantPlugin, transactionResolutionOf, withTransaction } from "@classytic/mongokit";
2
+ import { assertNoLegacyTenantKeys, resolveTenantConfig } from "@classytic/repo-core/tenant";
3
+ import { InProcessEventBus } from "@classytic/primitives/event-infra";
2
4
  import { Schema } from "mongoose";
3
5
  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";
6
+ import { normalizeEmail as normalizeEmail$1 } from "@classytic/contact/email";
7
+ import { PhoneFormatError, normalizePhone } from "@classytic/contact/phone";
8
+ import { createEvent as createEvent$1 } from "@classytic/primitives/events";
9
+ import { idToString } from "@classytic/primitives/reference";
7
10
  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
11
  //#region src/domain/enums.ts
79
12
  /**
80
13
  * Party-native enumerations.
@@ -126,6 +59,92 @@ const ADDRESS_TYPES = [
126
59
  "other"
127
60
  ];
128
61
  //#endregion
62
+ //#region src/events/dispatch.ts
63
+ /** Context key carrying a post-commit publish queue (attached by `withTransaction`). */
64
+ const PENDING_EVENTS = Symbol.for("@classytic/party:pendingEvents");
65
+ var PartyEventDispatcher = class {
66
+ name;
67
+ transport;
68
+ outbox;
69
+ logger;
70
+ constructor(opts) {
71
+ this.transport = opts.transport;
72
+ this.outbox = opts.outbox;
73
+ this.logger = opts.logger;
74
+ this.name = opts.transport.name;
75
+ }
76
+ /** Raw publish — hosts and the unit-of-work flush use this. */
77
+ publish(event) {
78
+ return this.transport.publish(event);
79
+ }
80
+ subscribe(pattern, handler) {
81
+ const subscribe = this.transport.subscribe?.bind(this.transport);
82
+ if (!subscribe) throw new Error("party: configured event transport does not support subscribe");
83
+ return subscribe(pattern, handler);
84
+ }
85
+ /**
86
+ * Route a domain event with full §P8 discipline. Reads the pending queue and
87
+ * session off `ctx`.
88
+ */
89
+ async dispatch(event, ctx) {
90
+ if (this.outbox) try {
91
+ await this.outbox.save(event, ctx.session !== void 0 ? { session: ctx.session } : {});
92
+ } catch (err) {
93
+ this.logger?.error?.(`[party] outbox.save failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
94
+ throw err;
95
+ }
96
+ const queue = ctx[PENDING_EVENTS];
97
+ if (Array.isArray(queue)) {
98
+ queue.push(event);
99
+ return;
100
+ }
101
+ try {
102
+ await this.transport.publish(event);
103
+ } catch (err) {
104
+ this.logger?.error?.(`[party] publish failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
105
+ }
106
+ }
107
+ async close() {
108
+ await this.transport.close?.();
109
+ }
110
+ };
111
+ /**
112
+ * Drain a pending-events queue to the transport AFTER the owning transaction
113
+ * commits. Per-event failures are swallowed — durable delivery is the outbox's
114
+ * job (PACKAGE_RULES §P8.1).
115
+ */
116
+ async function flushPending(dispatcher, queue, logger) {
117
+ for (const event of queue) try {
118
+ await dispatcher.publish(event);
119
+ } catch (err) {
120
+ logger?.error?.(`[party] post-commit publish failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
121
+ }
122
+ }
123
+ //#endregion
124
+ //#region src/events/in-process-bus.ts
125
+ /**
126
+ * In-process event bus — default fallback when no `eventTransport` is given to
127
+ * `createParty`.
128
+ *
129
+ * A thin subclass of `@classytic/primitives/event-infra`'s shared
130
+ * `InProcessEventBus`. Prior semantics preserved: glob matching via
131
+ * `matchEventPattern`, per-handler error isolation, idempotent `close()`.
132
+ * Party historically SWALLOWED handler errors when no logger was supplied —
133
+ * that default is preserved by passing `logger: null` unless the host injects
134
+ * a `PartyLogger`, whose structured `error(msg, meta)` slot is bridged onto
135
+ * the shared error-logger contract.
136
+ */
137
+ var InProcessPartyBus = class extends InProcessEventBus {
138
+ constructor(options) {
139
+ const partyLogger = options?.logger;
140
+ super({
141
+ name: "in-process-party",
142
+ logLabel: "party",
143
+ logger: partyLogger?.error ? { error: (message, ...args) => partyLogger.error?.(message, { args }) } : null
144
+ });
145
+ }
146
+ };
147
+ //#endregion
129
148
  //#region src/models/tenant-field.ts
130
149
  /**
131
150
  * Per PACKAGE_RULES §9.2: never hardcode the tenant column's Mongoose type —
@@ -144,15 +163,15 @@ const ADDRESS_TYPES = [
144
163
  * company-wide (`multiTenant: false`) so the field is simply not added.
145
164
  */
146
165
  function tenantFieldDef(config) {
147
- if (!config.multiTenant) return void 0;
148
- return config.tenantFieldType === "objectId" ? {
166
+ if (!config.tenant.enabled) return void 0;
167
+ return config.tenant.fieldType === "objectId" ? {
149
168
  type: Schema.Types.ObjectId,
150
- required: true,
151
- ref: "organization",
169
+ required: config.tenant.required,
170
+ ref: config.tenant.ref,
152
171
  index: true
153
172
  } : {
154
173
  type: String,
155
- required: true,
174
+ required: config.tenant.required,
156
175
  index: true
157
176
  };
158
177
  }
@@ -218,6 +237,11 @@ const contactPointSchema = new Schema({
218
237
  type: String,
219
238
  required: true
220
239
  },
240
+ input: {
241
+ type: String,
242
+ trim: true,
243
+ maxlength: 320
244
+ },
221
245
  isPrimary: {
222
246
  type: Boolean,
223
247
  default: false
@@ -327,7 +351,7 @@ const externalRefSchema$1 = new Schema({
327
351
  function buildPartySchema(config) {
328
352
  const tenantField = tenantFieldDef(config);
329
353
  const schema = new Schema({
330
- ...tenantField ? { organizationId: tenantField } : {},
354
+ ...tenantField ? { [config.tenant.tenantField]: tenantField } : {},
331
355
  kind: {
332
356
  type: String,
333
357
  enum: PARTY_KINDS,
@@ -400,11 +424,14 @@ function buildPartySchema(config) {
400
424
  toJSON: { virtuals: true },
401
425
  toObject: { virtuals: true }
402
426
  });
403
- const scoped = (fields) => config.multiTenant ? {
404
- organizationId: 1,
427
+ const scoped = (fields) => config.tenant.enabled ? {
428
+ [config.tenant.tenantField]: 1,
405
429
  ...fields
406
430
  } : fields;
407
- schema.index(scoped({ identityKeys: 1 }), { unique: true });
431
+ schema.index(scoped({ identityKeys: 1 }), {
432
+ unique: true,
433
+ partialFilterExpression: { "identityKeys.0": { $exists: true } }
434
+ });
408
435
  schema.index(scoped({
409
436
  roles: 1,
410
437
  status: 1
@@ -428,21 +455,121 @@ function buildPartySchema(config) {
428
455
  }
429
456
  //#endregion
430
457
  //#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;
458
+ /**
459
+ * Party model specification — the ONE authoritative place the Party model is described.
460
+ *
461
+ * `definePartyModels(config)` returns a connection-free mongokit `ModelBlueprint`; `defineParty`
462
+ * binds it during engine construction. Collision handling, auto-index and (future) collection
463
+ * config live here once (PACKAGE_RULES §20/§21). A name collision surfaces as mongokit's typed
464
+ * `ModelCollisionError`.
465
+ */
466
+ /**
467
+ * Describe the Party model set as a connection-free blueprint. Pure — builds no schema and
468
+ * registers nothing until `.bind(connection)`.
469
+ */
470
+ function definePartyModels(config) {
435
471
  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)) });
472
+ return defineModels({
473
+ models: [{
474
+ name: "Party",
475
+ schema: () => {
476
+ const schema = buildPartySchema(config);
477
+ schema.set("autoIndex", autoIndex);
478
+ return schema;
479
+ },
480
+ existing: config.forceRecreate ? {
481
+ mode: "replace",
482
+ environment: "test"
483
+ } : { mode: "throw" }
484
+ }],
485
+ assemble: (models) => Object.freeze({ Party: models.get("Party") })
486
+ });
444
487
  }
445
488
  //#endregion
489
+ //#region src/domain/errors.ts
490
+ /** Base class so hosts can `instanceof PartyError` across every party throwable. */
491
+ var PartyError = class extends Error {
492
+ status;
493
+ code;
494
+ meta;
495
+ constructor(status, code, message, meta) {
496
+ super(message);
497
+ this.name = new.target.name;
498
+ this.status = status;
499
+ this.code = code;
500
+ if (meta !== void 0) this.meta = meta;
501
+ }
502
+ };
503
+ /** A referenced party id does not exist (or is out of the caller's scope). */
504
+ var PartyNotFoundError = class extends PartyError {
505
+ constructor(id) {
506
+ super(404, "party.not_found", `Party "${id}" not found.`, { partyId: id });
507
+ }
508
+ };
509
+ /**
510
+ * A merge was attempted against an illegal target — self-merge, merging into an
511
+ * already-merged (redirected) party, or a cross-scope pair. Carries both ids.
512
+ */
513
+ var PartyMergeError = class extends PartyError {
514
+ constructor(message, sourceId, targetId) {
515
+ super(409, "party.merge_conflict", message, {
516
+ sourceId,
517
+ targetId
518
+ });
519
+ }
520
+ };
521
+ /**
522
+ * `resolveOrCreate` lost the create race and the winning row could not be
523
+ * re-read (should be transient — surfaced rather than swallowed).
524
+ */
525
+ var PartyResolveConflictError = class extends PartyError {
526
+ constructor(identityKey) {
527
+ super(409, "party.resolve_conflict", `Concurrent create for identity "${identityKey}" could not be reconciled.`, { identityKey });
528
+ }
529
+ };
530
+ /**
531
+ * Optimistic-concurrency guard failed — the party was modified by another
532
+ * writer between read and write. Callers re-fetch and retry.
533
+ */
534
+ var PartyVersionConflictError = class extends PartyError {
535
+ constructor(id, expected) {
536
+ super(409, "party.version_conflict", `Party "${id}" was modified concurrently (expected v${expected}).`, {
537
+ partyId: id,
538
+ expectedVersion: expected
539
+ });
540
+ }
541
+ };
542
+ /** Input failed a domain-verb precondition (before any DB write). */
543
+ var PartyValidationError = class extends PartyError {
544
+ constructor(message, meta) {
545
+ super(400, "party.validation", message, meta);
546
+ }
547
+ };
548
+ /**
549
+ * A phone contact point could not be normalized to E.164 — so it cannot become
550
+ * an identity key.
551
+ *
552
+ * This REFUSES rather than degrades, and that is the whole point. The previous
553
+ * behaviour fell back to `+${digits}`, which produced keys like
554
+ * `phone:+01309000993` — neither a national number nor E.164. Two spellings of
555
+ * one member therefore keyed differently and `resolveOrCreate` forked the
556
+ * identity, silently: every screen looked correct and the membership history
557
+ * simply split in two (FAIL LOUD rule 2 — an input we do not understand must
558
+ * fail, not widen).
559
+ *
560
+ * `meta` names the offending value and the region it was interpreted in, so an
561
+ * operator can fix the data instead of guessing which field a 400 refers to.
562
+ */
563
+ var PartyPhoneFormatError = class extends PartyError {
564
+ constructor(value, defaultRegion, detail) {
565
+ super(400, "party.phone_invalid", `Phone ${JSON.stringify(value)} is not a valid ${defaultRegion ?? "E.164"} number (${detail}). Identity requires an E.164 phone; supply a dialable number or configure the deployment region (\`defaultPhoneRegion\`) so national formats can be interpreted. It is NOT stored as typed — a raw spelling used as an identity key is how one person becomes several parties.`, {
566
+ value,
567
+ ...defaultRegion !== void 0 ? { defaultRegion } : {},
568
+ detail
569
+ });
570
+ }
571
+ };
572
+ //#endregion
446
573
  //#region src/domain/state-machine.ts
447
574
  /**
448
575
  * Party identity lifecycle FSM — the compile-time twin of the `repo.claim()`
@@ -471,32 +598,89 @@ const PARTY_STATE_MACHINE = defineStateMachine({
471
598
  * SAME key regardless of casing/spacing/punctuation, so `resolveOrCreate` finds
472
599
  * the existing party instead of forking a duplicate.
473
600
  *
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.
601
+ * ## Both rules live in `@classytic/contact`, imported DIRECTLY
602
+ *
603
+ * Neither implementation is here. Both are in `@classytic/contact`, a kernel
604
+ * below this one, and this file is a CALLER of it — not a re-export of it. That
605
+ * distinction is the whole point: the phone rule had three implementations (here,
606
+ * be-prod's `shared/phone.ts`, and hotel's), and a re-export would have been a
607
+ * fourth NAME for one rule, which is how the drift started. A consumer that wants
608
+ * to normalize a contact point imports `@classytic/contact/phone` or
609
+ * `@classytic/contact/email`; it does not reach through party.
610
+ *
611
+ * `@classytic/primitives/phone` deliberately does not do the phone work — it is a
612
+ * zero-dependency E.164 value object whose own docblock says *"deeper
613
+ * validation … is the host's call to layer on top"*. `@classytic/contact` IS that
614
+ * layer, and `ParsedPhone` extends the primitive rather than re-declaring it.
615
+ *
616
+ * ## What this file still owns: the WIRE contract
617
+ *
618
+ * `@classytic/contact` throws plain `Error` subclasses — it sits below every
619
+ * domain kernel and knows nothing about HTTP. Party owns a 400 /
620
+ * `party.phone_invalid` contract that arc serializes natively, so the boundary
621
+ * translation happens here, once, in the one function that derives the key.
622
+ *
623
+ * `PhoneRegionError` is deliberately NOT translated: a misconfigured
624
+ * `defaultPhoneRegion` is a deployment fault, not a customer's bad phone number,
625
+ * and turning it into a 400 on `customer.phone` would blame the wrong party (in
626
+ * both senses) for every number in the country.
627
+ *
628
+ * ## `@classytic/contact` is a PEER dependency, and that is load-bearing
629
+ *
630
+ * Not a `dependencies` entry. npm may nest a regular dependency under
631
+ * `party/node_modules/`, which would give the host a SECOND module instance —
632
+ * and then `err instanceof PhoneFormatError` is `false` at a host catch site
633
+ * that imported its own copy. The guard would compile, run, and match nothing.
634
+ * A peer forces exactly one copy per host, so class identity holds across the
635
+ * boundary.
636
+ *
637
+ * ## The region is CONFIGURATION, never a constant
638
+ *
639
+ * `defaultRegion` is an ISO 3166-1 alpha-2 code supplied by the deployment
640
+ * (`PartyShape.defaultPhoneRegion` → `deployment.config.ts`). This kernel names
641
+ * no country. With no region configured, only explicit E.164 input is accepted:
642
+ * refusing is correct, because guessing a country is how you mint a plausible
643
+ * wrong number.
644
+ */
645
+ /**
646
+ * Canonical email for the dedup key. Trim + lowercase, and deliberately NOT
647
+ * gmail dot / `+tag` folding — `a+shop@x.com` is a real, distinct inbox, and
648
+ * folding it MERGES two people, which is the irreversible direction.
649
+ *
650
+ * The rule moved to `@classytic/contact/email` unchanged for the canonical case,
651
+ * with one addition: an address that cannot be canonicalised now THROWS instead of
652
+ * becoming an identity key. Same reasoning as the phone side — a malformed address
653
+ * still makes a perfectly stable key, so admitting it does not fail, it forks.
479
654
  */
480
- /** Lowercase + trim an email. Conservative — never strips dots/plus tags. */
481
655
  function normalizeEmail(raw) {
482
- return raw.trim().toLowerCase();
656
+ return normalizeEmail$1(raw);
483
657
  }
484
658
  /**
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.
659
+ * Canonical E.164 form of a phone number, for dedup AND for storage.
660
+ *
661
+ * Delegates to `@classytic/contact`, then re-throws its refusal as party's own
662
+ * `PartyPhoneFormatError` (400 / `party.phone_invalid`) so arc serializes it
663
+ * natively. There is no fallback at either layer: the old `+${digits}`
664
+ * degradation is precisely what split one member into several parties, and it
665
+ * did so without erroring.
666
+ *
667
+ * NOT exported from this kernel's public surface. A consumer that wants to
668
+ * normalize a phone imports `@classytic/contact/phone` directly — one rule,
669
+ * one name. What party owns is `buildContactPoints`, the single point every
670
+ * identity write crosses.
490
671
  */
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}` : "";
672
+ function normalizePhone$1(raw, options = {}) {
673
+ const defaultRegion = options.defaultRegion;
674
+ try {
675
+ return normalizePhone(raw, { ...defaultRegion !== void 0 ? { defaultRegion } : {} });
676
+ } catch (err) {
677
+ if (err instanceof PhoneFormatError) throw new PartyPhoneFormatError(err.input, defaultRegion, err.detail ?? err.code);
678
+ throw err;
679
+ }
496
680
  }
497
681
  /** 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)}`;
682
+ function contactPointKey(type, value, options = {}) {
683
+ return `${type}:${type === "email" ? normalizeEmail(value) : normalizePhone$1(value, options)}`;
500
684
  }
501
685
  /** A tax-identity dedup key (`vat:...` / `bin:...`) — cross-scope-stable statutory id. */
502
686
  function taxIdentityKey(kind, value) {
@@ -517,28 +701,39 @@ function dedupe(arr) {
517
701
  /**
518
702
  * Turn the input's `emails[]` / `phones[]` into normalized `ContactPoint`s. The
519
703
  * first email and first phone are marked primary; blanks are skipped.
704
+ *
705
+ * THE SEAM. Both `resolveOrCreate` and `syncIdentity` funnel here, so this is
706
+ * the one place normalization can be enforced — a fix applied at N call sites
707
+ * is the same bug waiting at N+1.
708
+ *
709
+ * Two spellings of one number in a single input collapse to ONE point (they
710
+ * share a key). An unparseable phone THROWS rather than being skipped: skipping
711
+ * would create a party with no phone identity at all, which the next
712
+ * well-formed visit would not resolve onto — a silent fork by a different route.
520
713
  */
521
- function buildContactPoints(input) {
714
+ function buildContactPoints(input, options = {}) {
522
715
  const points = [];
523
- (input.emails ?? []).forEach((value, i) => {
524
- if (!value.trim()) return;
716
+ const seen = /* @__PURE__ */ new Set();
717
+ const push = (type, raw, canonical, isFirst) => {
718
+ const key = `${type}:${canonical}`;
719
+ if (seen.has(key)) return;
720
+ seen.add(key);
525
721
  points.push({
526
- type: "email",
527
- value,
528
- key: contactPointKey("email", value),
529
- isPrimary: i === 0,
722
+ type,
723
+ value: canonical,
724
+ key,
725
+ ...raw.trim() !== canonical ? { input: raw.trim() } : {},
726
+ isPrimary: isFirst,
530
727
  verified: false
531
728
  });
729
+ };
730
+ (input.emails ?? []).forEach((value, i) => {
731
+ if (!value.trim()) return;
732
+ push("email", value, normalizeEmail(value), i === 0);
532
733
  });
533
734
  (input.phones ?? []).forEach((value, i) => {
534
735
  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
- });
736
+ push("phone", value, normalizePhone$1(value, options), i === 0);
542
737
  });
543
738
  return points;
544
739
  }
@@ -547,8 +742,9 @@ function buildContactPoints(input) {
547
742
  * statutory ids, and external-store refs. Blank/degenerate keys are dropped so
548
743
  * they never poison the unique index.
549
744
  */
550
- function deriveIdentityKeys(contactPoints, input) {
551
- const keys = contactPoints.map((c) => c.key);
745
+ function deriveIdentityKeys(contactPoints, input, options = {}) {
746
+ const contactTypes = options.contactTypes ?? ["email", "phone"];
747
+ const keys = contactPoints.filter((c) => contactTypes.includes(c.type)).map((c) => c.key);
552
748
  if (input.taxIdentity?.vat) keys.push(taxIdentityKey("vat", input.taxIdentity.vat));
553
749
  if (input.taxIdentity?.bin) keys.push(taxIdentityKey("bin", input.taxIdentity.bin));
554
750
  for (const ref of input.externalRefs ?? []) keys.push(`ref:${ref.sourceModel}:${ref.sourceId}`);
@@ -608,6 +804,42 @@ function unionContacts(a, b) {
608
804
  }
609
805
  return out;
610
806
  }
807
+ /**
808
+ * Union postal addresses, deduping on `(type, line1, postalCode)` — the same
809
+ * address arriving from both sides collapses to one. Absorbed addresses keep
810
+ * their own recipient/label but yield `isDefault` to the survivor: a merge must
811
+ * never produce two default addresses (mirrors `unionContacts`' primary rule).
812
+ */
813
+ function unionAddresses(a, b) {
814
+ const key = (ad) => `${ad.type}|${(ad.line1 ?? "").trim().toLowerCase()}|${(ad.postalCode ?? "").trim().toLowerCase()}`;
815
+ const seen = new Set(a.map(key));
816
+ const out = [...a];
817
+ let hasDefault = a.some((ad) => ad.isDefault);
818
+ for (const ad of b) {
819
+ const k = key(ad);
820
+ if (seen.has(k)) continue;
821
+ seen.add(k);
822
+ const next = hasDefault && ad.isDefault ? {
823
+ ...ad,
824
+ isDefault: false
825
+ } : ad;
826
+ if (next.isDefault) hasDefault = true;
827
+ out.push(next);
828
+ }
829
+ return out;
830
+ }
831
+ /**
832
+ * Shallow-merge two metadata bags: the survivor (`a`) wins on every key it
833
+ * already holds; the loser (`b`) only FILLS keys the survivor is missing. Never
834
+ * overwrites a populated survivor field. `undefined` when both are empty.
835
+ */
836
+ function unionMetadata(a, b) {
837
+ if (a === void 0 && b === void 0) return void 0;
838
+ return {
839
+ ...b ?? {},
840
+ ...a ?? {}
841
+ };
842
+ }
611
843
  //#endregion
612
844
  //#region src/events/event-constants.ts
613
845
  /** PACKAGE_RULES §12: `package:resource.verb`. Emit ONLY these catalog names. */
@@ -665,9 +897,15 @@ function repoOptionsFromCtx(ctx) {
665
897
  const OBJECT_ID_HEX = /^[a-f0-9]{24}$/i;
666
898
  var PartyRepository = class extends Repository {
667
899
  events;
900
+ /** The ONE interpretation of free-form contact input in this deployment. */
901
+ normalize;
902
+ /** Contact data can exist without being strong enough to collapse two parties. */
903
+ identityContactTypes;
668
904
  constructor(model, deps, plugins = []) {
669
905
  super(model, plugins);
670
906
  this.events = deps.events;
907
+ this.normalize = deps.defaultPhoneRegion !== void 0 ? { defaultRegion: deps.defaultPhoneRegion } : {};
908
+ this.identityContactTypes = deps.identityContactTypes ?? ["email", "phone"];
671
909
  }
672
910
  /** `update` that never returns null — the party was loaded first; a null means a concurrent delete. */
673
911
  async mustUpdate(id, patch, ctx) {
@@ -676,9 +914,9 @@ var PartyRepository = class extends Repository {
676
914
  return updated;
677
915
  }
678
916
  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.");
917
+ const contactPoints = buildContactPoints(input, this.normalize);
918
+ const keys = deriveIdentityKeys(contactPoints, input, { contactTypes: this.identityContactTypes });
919
+ if (keys.length === 0 && contactPoints.length === 0 && !input.displayName?.trim() && !input.personName && !input.legalName?.trim()) throw new PartyValidationError("resolveOrCreate needs at least one contact/identity signal (email/phone/vat/bin/externalRef) or a name.");
682
920
  if (keys.length > 0) {
683
921
  const existing = await this.getByQuery({
684
922
  identityKeys: { $in: keys },
@@ -770,17 +1008,24 @@ var PartyRepository = class extends Repository {
770
1008
  */
771
1009
  async syncIdentity(id, input, ctx) {
772
1010
  const party = await this.loadParty(id, ctx);
773
- const mergedContacts = unionContacts(party.contactPoints, buildContactPoints(input));
1011
+ const mergedContacts = unionContacts(party.contactPoints, buildContactPoints(input, this.normalize));
1012
+ const mergedKeys = unionStrings(party.identityKeys, deriveIdentityKeys(mergedContacts, input, { contactTypes: this.identityContactTypes }));
774
1013
  const set = {
775
1014
  contactPoints: mergedContacts,
776
- identityKeys: unionStrings(party.identityKeys, deriveIdentityKeys(mergedContacts, input))
1015
+ identityKeys: mergedKeys
777
1016
  };
778
1017
  if (input.displayName?.trim()) set.displayName = input.displayName.trim();
779
1018
  if (input.legalName?.trim()) set.legalName = input.legalName.trim();
780
1019
  if (input.personName !== void 0) set.personName = input.personName;
781
1020
  if (input.addresses !== void 0) set.addresses = input.addresses;
782
1021
  if (input.taxIdentity !== void 0) set.taxIdentity = input.taxIdentity;
783
- const updated = await this.mustUpdate(party._id, { $set: set }, ctx);
1022
+ let updated;
1023
+ try {
1024
+ updated = await this.mustUpdate(party._id, { $set: set }, ctx);
1025
+ } catch (err) {
1026
+ if (isDuplicateKeyError(err)) throw new PartyResolveConflictError(mergedKeys.find((k) => !party.identityKeys.includes(k)) ?? mergedKeys[0] ?? "");
1027
+ throw err;
1028
+ }
784
1029
  await this.dispatch(PARTY_EVENTS.UPDATED, {
785
1030
  partyId: String(party._id),
786
1031
  changed: Object.keys(set)
@@ -820,7 +1065,27 @@ var PartyRepository = class extends Repository {
820
1065
  async attachExternalRef(id, ref, ctx) {
821
1066
  const party = await this.loadParty(id, ctx);
822
1067
  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);
1068
+ const refKey = `ref:${ref.sourceModel}:${ref.sourceId}`;
1069
+ let updated;
1070
+ try {
1071
+ updated = await this.mustUpdate(party._id, { $addToSet: {
1072
+ externalRefs: ref,
1073
+ identityKeys: refKey
1074
+ } }, ctx);
1075
+ } catch (err) {
1076
+ if (isDuplicateKeyError(err)) {
1077
+ const owner = await this.getByQuery({
1078
+ identityKeys: refKey,
1079
+ status: { $ne: "merged" }
1080
+ }, {
1081
+ ...repoOptionsFromCtx(ctx),
1082
+ throwOnNotFound: false
1083
+ });
1084
+ if (owner && String(owner._id) === String(party._id)) return owner;
1085
+ throw new PartyResolveConflictError(refKey);
1086
+ }
1087
+ throw err;
1088
+ }
824
1089
  await this.dispatch(PARTY_EVENTS.EXTERNAL_REF_ATTACHED, {
825
1090
  partyId: String(party._id),
826
1091
  sourceModel: ref.sourceModel,
@@ -852,9 +1117,17 @@ var PartyRepository = class extends Repository {
852
1117
  }
853
1118
  /**
854
1119
  * 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).
1120
+ * keys, contact points, addresses, roles, external refs, user links, tags, and
1121
+ * metadata, and FILLS its empty scalar slots (`taxIdentity`, `personName`,
1122
+ * `legalName`, `parentId`) from the loser — never overwriting a populated
1123
+ * survivor field. The loser becomes a terminal `merged` redirect
1124
+ * (`mergedInto`). Two-document write → runs in a transaction (falls back to
1125
+ * sequential on standalone Mongo).
1126
+ *
1127
+ * Concurrency: the loser is claimed `active|archived → merged` via CAS
1128
+ * (`repo.claim`), so a second concurrent merge of the same source loses the
1129
+ * race and surfaces a typed `PartyMergeError` — never a raw `E11000`. A
1130
+ * duplicate-key collision while adopting the loser's keys maps to the same.
858
1131
  */
859
1132
  async merge(sourceId, targetId, ctx) {
860
1133
  if (sourceId === targetId) throw new PartyMergeError("Cannot merge a party into itself.", sourceId, targetId);
@@ -867,27 +1140,46 @@ var PartyRepository = class extends Repository {
867
1140
  const mergedUsers = unionStrings(target.userLinks, source.userLinks);
868
1141
  const mergedRefs = unionRefs(target.externalRefs, source.externalRefs);
869
1142
  const mergedContacts = unionContacts(target.contactPoints, source.contactPoints);
1143
+ const mergedAddresses = unionAddresses(target.addresses, source.addresses);
1144
+ const mergedTags = unionStrings(target.tags, source.tags);
1145
+ const mergedMetadata = unionMetadata(target.metadata, source.metadata);
870
1146
  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 });
1147
+ const fill = {};
1148
+ if (target.taxIdentity === void 0 && source.taxIdentity !== void 0) fill.taxIdentity = source.taxIdentity;
1149
+ if (target.personName === void 0 && source.personName !== void 0) fill.personName = source.personName;
1150
+ if (!target.legalName?.trim() && source.legalName?.trim()) fill.legalName = source.legalName;
1151
+ if (target.parentId === void 0 && source.parentId !== void 0) fill.parentId = source.parentId;
1152
+ try {
1153
+ await withTransaction(this.Model.db, async (session) => {
1154
+ if (!await this.claim(String(source._id), {
1155
+ from: ["active", "archived"],
1156
+ to: "merged"
1157
+ }, { $set: {
1158
+ mergedInto: target._id,
1159
+ identityKeys: []
1160
+ } }, {
1161
+ ...repoOptionsFromCtx(ctx),
1162
+ session
1163
+ })) throw new PartyMergeError("Source party was concurrently modified (no longer mergeable).", sourceId, targetId);
1164
+ await this.update(target._id, { $set: {
1165
+ identityKeys: mergedKeys,
1166
+ roles: mergedRoles,
1167
+ userLinks: mergedUsers,
1168
+ externalRefs: mergedRefs,
1169
+ contactPoints: mergedContacts,
1170
+ addresses: mergedAddresses,
1171
+ tags: mergedTags,
1172
+ ...fill,
1173
+ ...mergedMetadata !== void 0 ? { metadata: mergedMetadata } : {}
1174
+ } }, {
1175
+ ...repoOptionsFromCtx(ctx),
1176
+ session
1177
+ });
1178
+ }, { allowFallback: true });
1179
+ } catch (err) {
1180
+ if (isDuplicateKeyError(err)) throw new PartyMergeError("Concurrent merge collided on a shared identity key.", sourceId, targetId);
1181
+ throw err;
1182
+ }
891
1183
  const survivor = await this.loadParty(targetId, ctx);
892
1184
  await this.dispatch(PARTY_EVENTS.MERGED, {
893
1185
  sourceId,
@@ -931,113 +1223,43 @@ var PartyRepository = class extends Repository {
931
1223
  }
932
1224
  };
933
1225
  //#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
1226
+ //#region src/engine/ensure-ready.ts
995
1227
  /**
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.
1228
+ * Boot capability gate (PACKAGE_RULES §capabilities). `resolveOrCreate`'s
1229
+ * race-safety leans on the unique-index E11000 path, and `merge` writes two
1230
+ * documents in a transaction — so the backend MUST support `upsert` +
1231
+ * `duplicateKeyError`, and `transactions` (unless the host opts out).
1232
+ * Fail loud at engine creation, not with a cryptic error on the first merge.
1233
+ *
1234
+ * ## This gate could not fire until 2026-08-05
1235
+ *
1236
+ * `repo.capabilities` used to resolve to mongokit's STATIC
1237
+ * `MONGOKIT_CAPABILITIES`, whose `transactions` was hard-coded `true`. So the
1238
+ * check below was structurally unfalsifiable for every mongoose-backed
1239
+ * repository — including one pointed at a standalone `mongod`, the exact
1240
+ * deployment it exists to catch. (party's kernel-conformance run had to SKIP
1241
+ * `bind-verifies-requirements` for precisely this reason.) mongokit now
1242
+ * OBSERVES the live deployment topology per connection, so the same code path
1243
+ * now genuinely refuses a standalone.
1244
+ *
1245
+ * ## Three answers, not two
1246
+ *
1247
+ * `transactionResolutionOf(caps)` distinguishes an OBSERVED "no" (a standalone
1248
+ * — fix the deployment) from "we could not determine it" (bound before the
1249
+ * connection opened, an unreadable driver). Both refuse, because an unobserved
1250
+ * outcome is not a positive one (AGENTS.md FAIL LOUD rule 3) — but the message
1251
+ * differs, because the operator's fix does.
1000
1252
  */
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
1253
  function assertPartyCapabilities(repo, opts = {}) {
1033
1254
  const caps = repo.capabilities;
1034
1255
  if (!caps) throw new Error("[party] backend declares no `capabilities` descriptor (needs repo-core 0.6 / mongokit 3.16+).");
1035
1256
  const missing = ["duplicateKeyError"].filter((flag) => caps[flag] !== true);
1036
1257
  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
1258
  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.`);
1259
+ const indeterminate = transactionResolutionOf(caps) === "unknown";
1260
+ const cause = indeterminate ? "[party] could NOT determine whether the backend supports transactions (the connection was not open at bind time, or the driver exposed no topology). An unconfirmed answer is treated as unsupported — `merge` writes two documents and needs atomicity." : "[party] backend does not support transactions — `merge` writes two documents and needs a replica set for atomicity.";
1261
+ if (opts.allowNonTransactional) opts.logger?.warn?.(`${cause} allowNonTransactional=true: merge falls back to sequential writes (partial-write risk).`);
1262
+ else throw new Error(`${cause} ${indeterminate ? "Bind AFTER `await mongoose.connect(...)`, or `await probeMongoCapabilities(connection)` first, or pass allowNonTransactional: true." : "Run MongoDB as a replica set, or pass allowNonTransactional: true."}`);
1041
1263
  }
1042
1264
  }
1043
1265
  //#endregion
@@ -1072,83 +1294,173 @@ async function runUnit(config, ctx, body) {
1072
1294
  return result;
1073
1295
  }
1074
1296
  //#endregion
1075
- //#region src/engine/create-party.ts
1297
+ //#region src/engine/define-party.ts
1076
1298
  /**
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';
1299
+ * `defineParty` — the DESCRIBE/BIND blueprint for the party engine
1300
+ * (STANDARDIZATION-PLAN §6). Splits construction into two phases:
1090
1301
  *
1091
- * const party = await createParty({ connection: mongoose.connection, multiTenant: false });
1302
+ * - **describe** (`defineParty(shape)`): pure. Validates the model set and freezes the
1303
+ * model names via mongokit's `defineModels()`. No connection, no schema built, no I/O —
1304
+ * safe to call at import / module-composition time.
1305
+ * - **bind** (`blueprint.bind(connection, runtime)`): compiles the schema on the supplied
1306
+ * connection, registers the model, wires the transport/dispatcher/repository, verifies
1307
+ * capabilities, and returns the frozen `PartyEngine`.
1092
1308
  *
1093
- * const { party: p } = await party.port.resolveOrCreate(
1094
- * { displayName: 'Ada Lovelace', emails: ['ada@x.com'], roles: ['customer'] },
1095
- * {},
1096
- * );
1097
- * ```
1309
+ * This is the sole construction API — there is no `createParty` wrapper.
1098
1310
  */
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) } } : {}
1311
+ /**
1312
+ * Describe the party engine. Pure — registers no model, builds no schema, performs no I/O.
1313
+ */
1314
+ function defineParty(shape = {}) {
1315
+ /**
1316
+ * The pre-consolidation tenant keys are REFUSED, never absorbed.
1317
+ *
1318
+ * This entry reads `shape.tenant`, so a leftover `multiTenant` is invisible and
1319
+ * the `?? true` default applies repo-core's defaults instead. Both outcomes
1320
+ * return plausible data:
1321
+ * - `multiTenant: false` -> tenancy ON, and the host loses its company-wide reads;
1322
+ * - `multiTenant: { tenantField: 'business' }` -> scoping on `organizationId`,
1323
+ * a field those documents do not carry.
1324
+ *
1325
+ * tsc cannot cover this: excess-property checking does not fire for a typed
1326
+ * variable, a spread, or a shape handed over from a dependency's dist — which is
1327
+ * how every real host composes one. Hence a describe-time check, shared from
1328
+ * repo-core so the nine kernels making this same rename cannot each forget it.
1329
+ */
1330
+ assertNoLegacyTenantKeys(shape, "defineParty", [["tenantFieldType", "tenant.fieldType"]]);
1331
+ const tenant = resolveTenantConfig(shape.tenant ?? true);
1332
+ if (tenant.strategy === "custom") throw new Error("defineParty: tenant.strategy 'custom' is not supported");
1333
+ const autoIndex = shape.autoIndex ?? true;
1334
+ const identityContactTypes = [...new Set(shape.identityContactTypes ?? CONTACT_POINT_TYPES)];
1335
+ const invalidIdentityContactType = identityContactTypes.find((type) => !CONTACT_POINT_TYPES.includes(type));
1336
+ if (invalidIdentityContactType !== void 0) throw new Error(`defineParty: identityContactTypes contains unsupported value '${String(invalidIdentityContactType)}'. Expected: ${CONTACT_POINT_TYPES.join(", ")}.`);
1337
+ const modelBlueprint = definePartyModels({
1338
+ tenant,
1339
+ autoIndex,
1340
+ ...shape.forceRecreate !== void 0 ? { forceRecreate: shape.forceRecreate } : {}
1130
1341
  });
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,
1342
+ return {
1343
+ id: "party",
1344
+ modelNames: modelBlueprint.modelNames,
1345
+ bind(connection, runtime = {}) {
1346
+ /**
1347
+ * BOOT GATE — an injected outbox MUST enlist `ctx.session`.
1348
+ *
1349
+ * FIRST statement, before the model blueprint binds: a pure config
1350
+ * assertion should report before any resource is allocated, and a refused
1351
+ * bind must leave the connection's model registry untouched.
1352
+ *
1353
+ * `OutboxWriteOptions.session` is best-effort BY CONTRACT (see
1354
+ * `@classytic/primitives/outbox`), so a hand-rolled store satisfies
1355
+ * `OutboxStore` and then keeps the row when the transaction aborts.
1356
+ * `PartyEventDispatcher.dispatch` saves under `ctx.session` precisely so it
1357
+ * cannot: identity is the ROOT every facet keys off, and a
1358
+ * `party.merged` that survives a rolled-back merge tells every downstream
1359
+ * consumer — customer, supplier, loyalty, accounting — to re-point at a
1360
+ * party that was never merged. Nothing throws; the divergence shows up as
1361
+ * facets pointing at an id that does not exist.
1362
+ *
1363
+ * Discovering that on the first real merge is far worse than at boot.
1364
+ */
1365
+ if (runtime.outbox && runtime.outbox.transactionalSave !== true) throw new Error("defineParty.bind: the configured OutboxStore does not declare `transactionalSave: true`, so it does not promise that `save()` enlists `ctx.session` (see @classytic/primitives/outbox — the option is best-effort by contract). Party writes every event row inside the transaction of its caller; a store that ignores the session emits merge/archive events for identities that rolled back. Arc's `repositoryAsOutboxStore` declares it; the in-memory `MemoryOutboxStore` must not — pass no outbox instead.");
1366
+ const models = modelBlueprint.bind(connection);
1367
+ const resolved = {
1368
+ connection,
1369
+ tenant,
1370
+ forceRecreate: shape.forceRecreate ?? false,
1371
+ autoIndex,
1372
+ identityContactTypes: Object.freeze([...identityContactTypes]),
1373
+ ...shape.defaultPhoneRegion !== void 0 ? { defaultPhoneRegion: shape.defaultPhoneRegion } : {},
1374
+ allowNonTransactional: runtime.allowNonTransactional ?? false,
1375
+ ...runtime.logger !== void 0 ? { logger: runtime.logger } : {},
1376
+ ...runtime.pagination !== void 0 ? { pagination: runtime.pagination } : {},
1377
+ plugins: runtime.plugins ?? []
1378
+ };
1379
+ const ownsTransport = runtime.eventTransport === void 0;
1380
+ const events = new PartyEventDispatcher({
1381
+ transport: runtime.eventTransport ?? new InProcessPartyBus(runtime.logger ? { logger: runtime.logger } : void 0),
1382
+ ...runtime.outbox !== void 0 ? { outbox: runtime.outbox } : {},
1383
+ ...runtime.logger !== void 0 ? { logger: runtime.logger } : {}
1384
+ });
1385
+ const plugins = [...tenant.enabled ? [multiTenantPlugin({
1386
+ tenantField: tenant.tenantField,
1387
+ contextKey: tenant.contextKey,
1388
+ fieldType: tenant.fieldType,
1389
+ required: tenant.required
1390
+ })] : [], ...resolved.plugins];
1391
+ const repository = new PartyRepository(models.Party, {
1140
1392
  events,
1141
1393
  ...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();
1394
+ ...resolved.defaultPhoneRegion !== void 0 ? { defaultPhoneRegion: resolved.defaultPhoneRegion } : {},
1395
+ identityContactTypes: resolved.identityContactTypes
1396
+ }, plugins);
1397
+ assertPartyCapabilities(repository, {
1398
+ allowNonTransactional: resolved.allowNonTransactional,
1399
+ ...resolved.logger?.warn !== void 0 ? { logger: { warn: (m) => resolved.logger?.warn?.(m) } } : {}
1400
+ });
1401
+ const close = async () => {
1402
+ if (ownsTransport) await events.close();
1403
+ };
1404
+ return Object.freeze({
1405
+ models,
1406
+ repositories: Object.freeze({ party: repository }),
1407
+ port: repository,
1408
+ events,
1409
+ config: Object.freeze(resolved),
1410
+ withTransaction(ctx, body) {
1411
+ return runUnit({
1412
+ connection,
1413
+ events,
1414
+ ...resolved.logger !== void 0 ? { logger: resolved.logger } : {},
1415
+ allowNonTransactional: resolved.allowNonTransactional
1416
+ }, ctx, body);
1417
+ },
1418
+ async syncIndexes() {
1419
+ await Promise.all(Object.values(models).map((m) => m.syncIndexes()));
1420
+ },
1421
+ close
1422
+ });
1150
1423
  }
1151
- });
1424
+ };
1425
+ }
1426
+ //#endregion
1427
+ //#region src/domain/subject.ts
1428
+ /**
1429
+ * The canonical `subjectModel` value for a Party.
1430
+ *
1431
+ * Import this — never write `'Party'` in a host, a module, or a test.
1432
+ */
1433
+ const PARTY_SUBJECT_MODEL = "Party";
1434
+ /**
1435
+ * Build the subject pair for a Party.
1436
+ *
1437
+ * Accepts anything id-shaped (`ObjectId`, hex string, a document's `_id`) and
1438
+ * stringifies through primitives' `idToString`, because the receiving kernels
1439
+ * store `subjectRef` as a String — a raw `ObjectId` would stringify implicitly
1440
+ * somewhere downstream and the two forms would not compare equal.
1441
+ *
1442
+ * Returns `null` for a missing id rather than a pair containing `"undefined"`.
1443
+ * That string is a real hazard here: it is a *valid* `subjectRef` as far as the
1444
+ * schema is concerned, so it persists, matches nothing, and looks like data.
1445
+ */
1446
+ function partySubject(id) {
1447
+ if (id === null || id === void 0) return null;
1448
+ const subjectRef = idToString(id);
1449
+ if (subjectRef.length === 0 || subjectRef === "undefined" || subjectRef === "null") return null;
1450
+ return {
1451
+ subjectRef,
1452
+ subjectModel: PARTY_SUBJECT_MODEL
1453
+ };
1454
+ }
1455
+ /**
1456
+ * Does this subject pair refer to a Party?
1457
+ *
1458
+ * For a resolver that fans a mixed subject stream out to the right kernel — an
1459
+ * attendance list can hold Party members and, in an HR deployment, employees
1460
+ * from another identity source.
1461
+ */
1462
+ function isPartySubject(subject) {
1463
+ return subject.subjectModel === PARTY_SUBJECT_MODEL;
1152
1464
  }
1153
1465
  //#endregion
1154
1466
  //#region src/events/event-catalog.ts
@@ -1266,4 +1578,4 @@ const partyEventDefinitions = [
1266
1578
  })
1267
1579
  ];
1268
1580
  //#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 };
1581
+ export { ADDRESS_TYPES, CONTACT_POINT_TYPES, InProcessPartyBus, PARTY_EVENTS, PARTY_KINDS, PARTY_ROLES, PARTY_STATE_MACHINE, PARTY_STATUSES, PARTY_SUBJECT_MODEL, PENDING_EVENTS, PartyError, PartyEventDispatcher, PartyMergeError, PartyNotFoundError, PartyPhoneFormatError, PartyRepository, PartyResolveConflictError, PartyValidationError, PartyVersionConflictError, buildContactPoints, buildPartySchema, contactPointKey, createEvent, dedupe, defineParty, definePartyModels, deriveIdentityKeys, flushPending, isPartySubject, normalizeEmail, partyEventDefinitions, partySubject, pickPrimary, repoOptionsFromCtx, resolveDisplayName, taxIdentityKey, tenantFieldDef, unionContacts, unionRefs, unionStrings };