@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/CHANGELOG.md +59 -0
- package/LICENSE +1 -1
- package/README.md +29 -4
- package/dist/events.d.ts +3 -8
- package/dist/events.js +21 -31
- package/dist/index.d.ts +253 -90
- package/dist/index.js +641 -329
- package/dist/maintenance.d.ts +165 -0
- package/dist/maintenance.js +353 -0
- package/package.json +15 -8
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 {
|
|
5
|
-
import {
|
|
6
|
-
import { createEvent as createEvent$1
|
|
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.
|
|
148
|
-
return config.
|
|
166
|
+
if (!config.tenant.enabled) return void 0;
|
|
167
|
+
return config.tenant.fieldType === "objectId" ? {
|
|
149
168
|
type: Schema.Types.ObjectId,
|
|
150
|
-
required:
|
|
151
|
-
ref:
|
|
169
|
+
required: config.tenant.required,
|
|
170
|
+
ref: config.tenant.ref,
|
|
152
171
|
index: true
|
|
153
172
|
} : {
|
|
154
173
|
type: String,
|
|
155
|
-
required:
|
|
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 ? {
|
|
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.
|
|
404
|
-
|
|
427
|
+
const scoped = (fields) => config.tenant.enabled ? {
|
|
428
|
+
[config.tenant.tenantField]: 1,
|
|
405
429
|
...fields
|
|
406
430
|
} : fields;
|
|
407
|
-
schema.index(scoped({ identityKeys: 1 }), {
|
|
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
|
-
/**
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
*
|
|
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
|
|
656
|
+
return normalizeEmail$1(raw);
|
|
483
657
|
}
|
|
484
658
|
/**
|
|
485
|
-
* Canonical
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
*
|
|
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
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
524
|
-
|
|
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
|
|
527
|
-
value,
|
|
528
|
-
key
|
|
529
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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,
|
|
856
|
-
*
|
|
857
|
-
*
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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/
|
|
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
|
-
*
|
|
997
|
-
*
|
|
998
|
-
*
|
|
999
|
-
*
|
|
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
|
|
1039
|
-
|
|
1040
|
-
|
|
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/
|
|
1297
|
+
//#region src/engine/define-party.ts
|
|
1076
1298
|
/**
|
|
1077
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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,
|
|
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 };
|