@classytic/party 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/dist/events.d.ts +238 -0
- package/dist/events.js +255 -0
- package/dist/index.d.ts +592 -0
- package/dist/index.js +1269 -0
- package/dist/schemas.d.ts +125 -0
- package/dist/schemas.js +79 -0
- package/package.json +78 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@classytic/party` are documented here. Adheres to
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/) and [SemVer](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [0.1.0] - 2026-07-13
|
|
7
|
+
|
|
8
|
+
Initial release — the Party / Business-Partner identity kernel.
|
|
9
|
+
|
|
10
|
+
- **`createParty({ connection, multiTenant?, … })`** engine factory; owns one
|
|
11
|
+
`Party` Mongoose model, an event dispatcher, and a `PartyPort`.
|
|
12
|
+
- **`Party` model** — kind (person/company/group), non-exclusive roles,
|
|
13
|
+
contact points (normalized `identityKeys`), addresses, tax identity, B2B
|
|
14
|
+
`parentId`, 1:many `userLinks`, `externalRefs`, lifecycle (active/archived/merged).
|
|
15
|
+
- **`resolveOrCreate`** — race-safe dedup by email/phone/vat/bin/externalRef;
|
|
16
|
+
unique multikey `identityKeys` index is the authoritative guard.
|
|
17
|
+
- **`merge`** — non-lossy survivor absorption + terminal redirect; `snapshot`
|
|
18
|
+
follows the redirect. Runs in a transaction.
|
|
19
|
+
- **Roles** — `addRole` / `removeRole`; **lifecycle** — `archive` / `restore`
|
|
20
|
+
via a `defineStateMachine` + `claim` CAS.
|
|
21
|
+
- **`syncIdentity(id, input)`** — the canonical identity-mutation verb: merges
|
|
22
|
+
new contact points (deriving keys, extending `identityKeys`), updates
|
|
23
|
+
name/addresses/tax, emits `party:party.updated`. Hosts push a facet's current
|
|
24
|
+
identity here so the party stays the source of truth (idempotent).
|
|
25
|
+
- **`PartyPort`** — the reusable identity contract for consuming domains.
|
|
26
|
+
- **Events** — `party:party.*` with a Zod source catalog + no-drift test; P8.1
|
|
27
|
+
unit-of-work (`withTransaction` + `PENDING_EVENTS` + `flushPending`).
|
|
28
|
+
- Thin identity (SAP Business-Partner shape) — domain facets stay in their own
|
|
29
|
+
packages keyed by `partyId`. Primitives reused for Address / PersonName /
|
|
30
|
+
ExternalRef / phone parsing. 28 tests (18 unit + 10 integration).
|
|
31
|
+
- Toolchain: TypeScript 7, tsdown 0.22, ESM, `exactOptionalPropertyTypes`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Classytic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# @classytic/party
|
|
2
|
+
|
|
3
|
+
**The Party / Business-Partner identity kernel for MongoDB.** One canonical
|
|
4
|
+
identity per real-world person, company, or group — carrying non-exclusive roles
|
|
5
|
+
(customer / supplier / contact / employee / partner), contact points, addresses,
|
|
6
|
+
tax identity, B2B hierarchy, and 1:many auth-user links. With race-safe
|
|
7
|
+
de-duplication, merge, and a reusable resolver port.
|
|
8
|
+
|
|
9
|
+
Built on Mongoose + [`@classytic/mongokit`](https://npmjs.com/package/@classytic/mongokit)
|
|
10
|
+
+ [`@classytic/primitives`](https://npmjs.com/package/@classytic/primitives) +
|
|
11
|
+
[`@classytic/repo-core`](https://npmjs.com/package/@classytic/repo-core). Any
|
|
12
|
+
commerce / ERP / SaaS app on MongoDB can adopt it.
|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
Most stacks model "the customer" in several places — a storefront customer, a
|
|
17
|
+
CRM contact, an accounting partner, a payout recipient — none sharing an
|
|
18
|
+
identity. That fragmentation is where "is this the same person?" bugs live.
|
|
19
|
+
|
|
20
|
+
`party` is the **single identity spine**. It follows the **SAP Business Partner**
|
|
21
|
+
shape: a *thin* identity that carries roles, and lets each domain keep its own
|
|
22
|
+
**facet** keyed by `partyId`. It is deliberately **not** an Odoo `res.partner`
|
|
23
|
+
god-object — no accounting accounts, credit limits, loyalty tiers, KYC, or
|
|
24
|
+
payout methods live on the party. Those stay in `@classytic/ledger`,
|
|
25
|
+
`@classytic/loyalty`, `@classytic/payee`, `@classytic/crm`, … and point at a
|
|
26
|
+
`partyId`.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
@classytic/party (WHO) ← one deduped identity + roles
|
|
30
|
+
├── ledger finance facet (AR/AP, credit, terms) → partyId
|
|
31
|
+
├── crm funnel facet (stage, campaigns) → partyId
|
|
32
|
+
├── loyalty rewards facet (points, tier) → partyId
|
|
33
|
+
├── payee payout facet (methods, KYC) → partyId
|
|
34
|
+
├── commission earnings facet (plans, records) → partyId
|
|
35
|
+
└── referral attribution (who referred whom) → partyId
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm i @classytic/party
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Peers: `@classytic/mongokit >=3.20`, `@classytic/primitives >=0.9.1`,
|
|
45
|
+
`@classytic/repo-core >=0.9`, `mongoose >=9.4.1`, `zod >=4`.
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import mongoose from 'mongoose';
|
|
51
|
+
import { createParty } from '@classytic/party';
|
|
52
|
+
|
|
53
|
+
const party = await createParty({
|
|
54
|
+
connection: mongoose.connection,
|
|
55
|
+
multiTenant: false, // identity is company-wide here; pass true for per-tenant SaaS
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Resolve-or-create by ANY identity signal — race-safe, dedup by email/phone/vat.
|
|
59
|
+
const { party: ada, created } = await party.port.resolveOrCreate(
|
|
60
|
+
{
|
|
61
|
+
displayName: 'Ada Lovelace',
|
|
62
|
+
emails: ['ada@example.com'],
|
|
63
|
+
phones: ['+8801712345678'],
|
|
64
|
+
roles: ['customer'],
|
|
65
|
+
},
|
|
66
|
+
{},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Same person, different casing + a role added → SAME party, no duplicate.
|
|
70
|
+
await party.port.resolveOrCreate({ emails: ['ADA@example.com'], roles: ['supplier'] }, {});
|
|
71
|
+
|
|
72
|
+
// Freeze a snapshot onto an order/invoice (never a live join).
|
|
73
|
+
const snap = await party.port.snapshot(String(ada._id), {});
|
|
74
|
+
|
|
75
|
+
// Merge a duplicate — survivor absorbs identity; the loser becomes a redirect.
|
|
76
|
+
await party.repositories.party.merge(dupId, survivorId, {});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## The `PartyPort` — the reusable contract
|
|
80
|
+
|
|
81
|
+
Consuming domains depend on `engine.port` (a `PartyPort`), not the models:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
interface PartyPort<TParty> {
|
|
85
|
+
resolveOrCreate(input, ctx): Promise<{ party: TParty; created: boolean }>;
|
|
86
|
+
resolveMany(inputs, ctx): Promise<Array<{ party: TParty; created: boolean }>>;
|
|
87
|
+
snapshot(partyId, ctx): Promise<PartySnapshot>; // follows merge redirects
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Wire it into order / crm / ledger as the single identity source. Swap the
|
|
92
|
+
kernel's storage without touching a consumer.
|
|
93
|
+
|
|
94
|
+
## What it guarantees
|
|
95
|
+
|
|
96
|
+
- **Race-safe dedup** — a unique multikey index on `identityKeys` is the
|
|
97
|
+
authoritative guard; concurrent creates collapse to one party.
|
|
98
|
+
- **Non-lossy merge** — the survivor absorbs the loser's keys, roles, contacts,
|
|
99
|
+
refs, and user links; the loser stays as a terminal `merged` redirect, so
|
|
100
|
+
historical references (and `snapshot`) still resolve.
|
|
101
|
+
- **Arc-native errors** — every throwable implements repo-core's `HttpError`.
|
|
102
|
+
- **Event catalog** — `party:party.*` events with a Zod source catalog for arc's
|
|
103
|
+
`EventRegistry` (validation + OpenAPI). Bring any arc `EventTransport`.
|
|
104
|
+
|
|
105
|
+
## Roles are flags, never tables
|
|
106
|
+
|
|
107
|
+
A party with `roles: ['customer', 'supplier']` is simultaneously both. Grant with
|
|
108
|
+
`addRole`, revoke with `removeRole`. No separate customer/vendor/contact
|
|
109
|
+
entities — that's the duplication this kernel exists to remove.
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DomainEvent, EventHandler, EventMeta, EventTransport } from "@classytic/primitives/events";
|
|
3
|
+
import { Types } from "mongoose";
|
|
4
|
+
import { OperationContext } from "@classytic/primitives/context";
|
|
5
|
+
import "@classytic/primitives/reference";
|
|
6
|
+
import "@classytic/primitives/person";
|
|
7
|
+
import "@classytic/primitives/address";
|
|
8
|
+
import { OutboxStore } from "@classytic/primitives/outbox";
|
|
9
|
+
//#region src/events/event-constants.d.ts
|
|
10
|
+
/** PACKAGE_RULES §12: `package:resource.verb`. Emit ONLY these catalog names. */
|
|
11
|
+
declare const PARTY_EVENTS: {
|
|
12
|
+
readonly CREATED: 'party:party.created';
|
|
13
|
+
readonly UPDATED: 'party:party.updated';
|
|
14
|
+
readonly ROLE_ADDED: 'party:party.role_added';
|
|
15
|
+
readonly ROLE_REMOVED: 'party:party.role_removed';
|
|
16
|
+
readonly USER_LINKED: 'party:party.user_linked';
|
|
17
|
+
readonly EXTERNAL_REF_ATTACHED: 'party:party.external_ref_attached';
|
|
18
|
+
readonly ARCHIVED: 'party:party.archived';
|
|
19
|
+
readonly RESTORED: 'party:party.restored';
|
|
20
|
+
readonly MERGED: 'party:party.merged';
|
|
21
|
+
};
|
|
22
|
+
type PartyEventName = (typeof PARTY_EVENTS)[keyof typeof PARTY_EVENTS];
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/domain/enums.d.ts
|
|
25
|
+
/**
|
|
26
|
+
* Party-native enumerations.
|
|
27
|
+
*
|
|
28
|
+
* These are identity-kernel concepts (kind, role, lifecycle) — they have no
|
|
29
|
+
* one-to-one primitive to subset, so they are declared here as the source of
|
|
30
|
+
* truth. Domain FACETS (finance terms, CRM stage, loyalty tier) live in their
|
|
31
|
+
* OWN packages keyed by `partyId`; they are deliberately NOT modelled here —
|
|
32
|
+
* that separation is the whole point of the SAP-style thin identity kernel
|
|
33
|
+
* (vs. Odoo's `res.partner` god-object).
|
|
34
|
+
*/
|
|
35
|
+
/** What kind of real-world entity a party is. */
|
|
36
|
+
declare const PARTY_KINDS: readonly ['person', 'company', 'group'];
|
|
37
|
+
type PartyKind = (typeof PARTY_KINDS)[number];
|
|
38
|
+
/**
|
|
39
|
+
* Roles a party plays. NON-exclusive — one party can be a customer AND a
|
|
40
|
+
* supplier AND a contact simultaneously (the exact de-duplication a shared
|
|
41
|
+
* identity buys you). Roles are additive flags, never separate entities.
|
|
42
|
+
*/
|
|
43
|
+
declare const PARTY_ROLES: readonly ['customer', 'supplier', 'contact', 'employee', 'partner'];
|
|
44
|
+
type PartyRole = (typeof PARTY_ROLES)[number];
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/events/event-payloads.d.ts
|
|
47
|
+
interface PartyCreatedPayload {
|
|
48
|
+
partyId: string;
|
|
49
|
+
kind: PartyKind;
|
|
50
|
+
displayName: string;
|
|
51
|
+
roles: PartyRole[];
|
|
52
|
+
organizationId?: string;
|
|
53
|
+
}
|
|
54
|
+
interface PartyUpdatedPayload {
|
|
55
|
+
partyId: string;
|
|
56
|
+
/** Top-level fields that changed on this write. */
|
|
57
|
+
changed: string[];
|
|
58
|
+
}
|
|
59
|
+
interface PartyRoleChangedPayload {
|
|
60
|
+
partyId: string;
|
|
61
|
+
role: PartyRole;
|
|
62
|
+
}
|
|
63
|
+
interface PartyUserLinkedPayload {
|
|
64
|
+
partyId: string;
|
|
65
|
+
userId: string;
|
|
66
|
+
}
|
|
67
|
+
interface PartyExternalRefAttachedPayload {
|
|
68
|
+
partyId: string;
|
|
69
|
+
sourceModel: string;
|
|
70
|
+
sourceId: string;
|
|
71
|
+
}
|
|
72
|
+
interface PartyLifecyclePayload {
|
|
73
|
+
partyId: string;
|
|
74
|
+
}
|
|
75
|
+
interface PartyMergedPayload {
|
|
76
|
+
/** The party that was merged away (now a redirect). */
|
|
77
|
+
sourceId: string;
|
|
78
|
+
/** The surviving party. */
|
|
79
|
+
targetId: string;
|
|
80
|
+
/** How many identity keys the survivor absorbed. */
|
|
81
|
+
absorbedKeys: number;
|
|
82
|
+
}
|
|
83
|
+
/** Name → payload map — subscribers get exact typing from the event name. */
|
|
84
|
+
interface PartyEventPayloads {
|
|
85
|
+
[PARTY_EVENTS.CREATED]: PartyCreatedPayload;
|
|
86
|
+
[PARTY_EVENTS.UPDATED]: PartyUpdatedPayload;
|
|
87
|
+
[PARTY_EVENTS.ROLE_ADDED]: PartyRoleChangedPayload;
|
|
88
|
+
[PARTY_EVENTS.ROLE_REMOVED]: PartyRoleChangedPayload;
|
|
89
|
+
[PARTY_EVENTS.USER_LINKED]: PartyUserLinkedPayload;
|
|
90
|
+
[PARTY_EVENTS.EXTERNAL_REF_ATTACHED]: PartyExternalRefAttachedPayload;
|
|
91
|
+
[PARTY_EVENTS.ARCHIVED]: PartyLifecyclePayload;
|
|
92
|
+
[PARTY_EVENTS.RESTORED]: PartyLifecyclePayload;
|
|
93
|
+
[PARTY_EVENTS.MERGED]: PartyMergedPayload;
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/events/event-catalog.d.ts
|
|
97
|
+
interface PartyEventDefinition<TSchema extends z.ZodType = z.ZodType> {
|
|
98
|
+
name: string;
|
|
99
|
+
version: number;
|
|
100
|
+
description: string;
|
|
101
|
+
zodSchema: TSchema;
|
|
102
|
+
schema: Record<string, unknown>;
|
|
103
|
+
create(payload: z.infer<TSchema>, meta?: Partial<EventMeta>): DomainEvent<z.infer<TSchema>>;
|
|
104
|
+
}
|
|
105
|
+
declare const PartyCreated: PartyEventDefinition<z.ZodObject<{
|
|
106
|
+
partyId: z.ZodString;
|
|
107
|
+
kind: z.ZodEnum<{
|
|
108
|
+
company: "company";
|
|
109
|
+
group: "group";
|
|
110
|
+
person: "person";
|
|
111
|
+
}>;
|
|
112
|
+
displayName: z.ZodString;
|
|
113
|
+
roles: z.ZodArray<z.ZodEnum<{
|
|
114
|
+
contact: "contact";
|
|
115
|
+
customer: "customer";
|
|
116
|
+
employee: "employee";
|
|
117
|
+
partner: "partner";
|
|
118
|
+
supplier: "supplier";
|
|
119
|
+
}>>;
|
|
120
|
+
organizationId: z.ZodOptional<z.ZodString>;
|
|
121
|
+
}, z.core.$strip>>;
|
|
122
|
+
declare const PartyUpdated: PartyEventDefinition<z.ZodObject<{
|
|
123
|
+
partyId: z.ZodString;
|
|
124
|
+
changed: z.ZodArray<z.ZodString>;
|
|
125
|
+
}, z.core.$strip>>;
|
|
126
|
+
declare const PartyRoleAdded: PartyEventDefinition<z.ZodObject<{
|
|
127
|
+
partyId: z.ZodString;
|
|
128
|
+
role: z.ZodEnum<{
|
|
129
|
+
contact: "contact";
|
|
130
|
+
customer: "customer";
|
|
131
|
+
employee: "employee";
|
|
132
|
+
partner: "partner";
|
|
133
|
+
supplier: "supplier";
|
|
134
|
+
}>;
|
|
135
|
+
}, z.core.$strip>>;
|
|
136
|
+
declare const PartyRoleRemoved: PartyEventDefinition<z.ZodObject<{
|
|
137
|
+
partyId: z.ZodString;
|
|
138
|
+
role: z.ZodEnum<{
|
|
139
|
+
contact: "contact";
|
|
140
|
+
customer: "customer";
|
|
141
|
+
employee: "employee";
|
|
142
|
+
partner: "partner";
|
|
143
|
+
supplier: "supplier";
|
|
144
|
+
}>;
|
|
145
|
+
}, z.core.$strip>>;
|
|
146
|
+
declare const PartyUserLinked: PartyEventDefinition<z.ZodObject<{
|
|
147
|
+
partyId: z.ZodString;
|
|
148
|
+
userId: z.ZodString;
|
|
149
|
+
}, z.core.$strip>>;
|
|
150
|
+
declare const PartyExternalRefAttached: PartyEventDefinition<z.ZodObject<{
|
|
151
|
+
partyId: z.ZodString;
|
|
152
|
+
sourceModel: z.ZodString;
|
|
153
|
+
sourceId: z.ZodString;
|
|
154
|
+
}, z.core.$strip>>;
|
|
155
|
+
declare const PartyArchived: PartyEventDefinition<z.ZodObject<{
|
|
156
|
+
partyId: z.ZodString;
|
|
157
|
+
}, z.core.$strip>>;
|
|
158
|
+
declare const PartyRestored: PartyEventDefinition<z.ZodObject<{
|
|
159
|
+
partyId: z.ZodString;
|
|
160
|
+
}, z.core.$strip>>;
|
|
161
|
+
declare const PartyMerged: PartyEventDefinition<z.ZodObject<{
|
|
162
|
+
sourceId: z.ZodString;
|
|
163
|
+
targetId: z.ZodString;
|
|
164
|
+
absorbedKeys: z.ZodNumber;
|
|
165
|
+
}, z.core.$strip>>;
|
|
166
|
+
declare const partyEventDefinitions: ReadonlyArray<PartyEventDefinition>;
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region src/domain/ports.d.ts
|
|
169
|
+
/**
|
|
170
|
+
* Operation context threaded through every domain verb. Extends primitives'
|
|
171
|
+
* `OperationContext`; narrows the id fields to what Mongoose stores cleanly.
|
|
172
|
+
*/
|
|
173
|
+
interface PartyContext extends OperationContext {
|
|
174
|
+
organizationId?: string | Types.ObjectId;
|
|
175
|
+
actorId?: string | Types.ObjectId;
|
|
176
|
+
/** Mongo transaction session, passed straight to mongokit. */
|
|
177
|
+
session?: unknown;
|
|
178
|
+
/** Cooperative cancellation — threaded to mongokit at choke points. */
|
|
179
|
+
signal?: AbortSignal;
|
|
180
|
+
/** Retry policy for driver round-trips (mongokit `RetryPolicy`). */
|
|
181
|
+
retryPolicy?: unknown;
|
|
182
|
+
}
|
|
183
|
+
/** Minimal logger surface the in-process bus + repositories log through. */
|
|
184
|
+
interface PartyLogger {
|
|
185
|
+
info?(msg: string, meta?: Record<string, unknown>): void;
|
|
186
|
+
warn?(msg: string, meta?: Record<string, unknown>): void;
|
|
187
|
+
error?(msg: string, meta?: Record<string, unknown>): void;
|
|
188
|
+
debug?(msg: string, meta?: Record<string, unknown>): void;
|
|
189
|
+
}
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/events/helpers.d.ts
|
|
192
|
+
declare function createEvent<T>(type: string, payload: T, ctx?: PartyContext, meta?: Partial<EventMeta>): DomainEvent<T>;
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region src/events/in-process-bus.d.ts
|
|
195
|
+
declare class InProcessPartyBus implements EventTransport {
|
|
196
|
+
readonly name = "in-process-party";
|
|
197
|
+
private subs;
|
|
198
|
+
private readonly logger;
|
|
199
|
+
constructor(options?: {
|
|
200
|
+
logger?: PartyLogger | undefined;
|
|
201
|
+
});
|
|
202
|
+
publish(event: DomainEvent): Promise<void>;
|
|
203
|
+
subscribe(pattern: string, handler: EventHandler): Promise<() => void>;
|
|
204
|
+
close(): Promise<void>;
|
|
205
|
+
}
|
|
206
|
+
//#endregion
|
|
207
|
+
//#region src/events/dispatch.d.ts
|
|
208
|
+
/** Context key carrying a post-commit publish queue (attached by `withTransaction`). */
|
|
209
|
+
declare const PENDING_EVENTS: unique symbol;
|
|
210
|
+
interface PartyEventDispatcherOptions {
|
|
211
|
+
transport: EventTransport;
|
|
212
|
+
outbox?: OutboxStore | undefined;
|
|
213
|
+
logger?: PartyLogger | undefined;
|
|
214
|
+
}
|
|
215
|
+
declare class PartyEventDispatcher {
|
|
216
|
+
readonly name: string;
|
|
217
|
+
private readonly transport;
|
|
218
|
+
private readonly outbox;
|
|
219
|
+
private readonly logger;
|
|
220
|
+
constructor(opts: PartyEventDispatcherOptions);
|
|
221
|
+
/** Raw publish — hosts and the unit-of-work flush use this. */
|
|
222
|
+
publish(event: DomainEvent): Promise<void>;
|
|
223
|
+
subscribe(pattern: string, handler: EventHandler): Promise<() => void>;
|
|
224
|
+
/**
|
|
225
|
+
* Route a domain event with full §P8 discipline. Reads the pending queue and
|
|
226
|
+
* session off `ctx`.
|
|
227
|
+
*/
|
|
228
|
+
dispatch(event: DomainEvent, ctx: PartyContext): Promise<void>;
|
|
229
|
+
close(): Promise<void>;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Drain a pending-events queue to the transport AFTER the owning transaction
|
|
233
|
+
* commits. Per-event failures are swallowed — durable delivery is the outbox's
|
|
234
|
+
* job (PACKAGE_RULES §P8.1).
|
|
235
|
+
*/
|
|
236
|
+
declare function flushPending(dispatcher: PartyEventDispatcher, queue: DomainEvent[], logger?: PartyLogger): Promise<void>;
|
|
237
|
+
//#endregion
|
|
238
|
+
export { InProcessPartyBus, PARTY_EVENTS, PENDING_EVENTS, PartyArchived, PartyCreated, PartyCreatedPayload, PartyEventDefinition, PartyEventDispatcher, PartyEventDispatcherOptions, PartyEventName, PartyEventPayloads, PartyExternalRefAttached, PartyExternalRefAttachedPayload, PartyLifecyclePayload, PartyMerged, PartyMergedPayload, PartyRestored, PartyRoleAdded, PartyRoleChangedPayload, PartyRoleRemoved, PartyUpdated, PartyUpdatedPayload, PartyUserLinked, PartyUserLinkedPayload, createEvent, flushPending, partyEventDefinitions };
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { createEvent as createEvent$1, matchEventPattern } from "@classytic/primitives/events";
|
|
3
|
+
//#region src/events/event-constants.ts
|
|
4
|
+
/** PACKAGE_RULES §12: `package:resource.verb`. Emit ONLY these catalog names. */
|
|
5
|
+
const PARTY_EVENTS = {
|
|
6
|
+
CREATED: "party:party.created",
|
|
7
|
+
UPDATED: "party:party.updated",
|
|
8
|
+
ROLE_ADDED: "party:party.role_added",
|
|
9
|
+
ROLE_REMOVED: "party:party.role_removed",
|
|
10
|
+
USER_LINKED: "party:party.user_linked",
|
|
11
|
+
EXTERNAL_REF_ATTACHED: "party:party.external_ref_attached",
|
|
12
|
+
ARCHIVED: "party:party.archived",
|
|
13
|
+
RESTORED: "party:party.restored",
|
|
14
|
+
MERGED: "party:party.merged"
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/events/event-catalog.ts
|
|
18
|
+
/**
|
|
19
|
+
* Zod-source event catalog (PACKAGE_RULES §18). One Zod schema per event is the
|
|
20
|
+
* single source of truth — runtime validation, static types, and arc's
|
|
21
|
+
* `EventRegistry` (OpenAPI + `arc describe`) all derive from it. Structurally
|
|
22
|
+
* identical to arc's `EventDefinitionOutput`; we never import arc.
|
|
23
|
+
*
|
|
24
|
+
* Timestamps use `z.iso.datetime()`, never `z.coerce.date()` (§25).
|
|
25
|
+
*/
|
|
26
|
+
function definePartyEvent(input) {
|
|
27
|
+
const { name, version = 1, description, zodSchema } = input;
|
|
28
|
+
return {
|
|
29
|
+
name,
|
|
30
|
+
version,
|
|
31
|
+
description,
|
|
32
|
+
zodSchema,
|
|
33
|
+
schema: z.toJSONSchema(zodSchema),
|
|
34
|
+
create(payload, meta) {
|
|
35
|
+
return createEvent$1(name, payload, {
|
|
36
|
+
source: "party",
|
|
37
|
+
...meta
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const partyKind = z.enum([
|
|
43
|
+
"person",
|
|
44
|
+
"company",
|
|
45
|
+
"group"
|
|
46
|
+
]);
|
|
47
|
+
const partyRole = z.enum([
|
|
48
|
+
"customer",
|
|
49
|
+
"supplier",
|
|
50
|
+
"contact",
|
|
51
|
+
"employee",
|
|
52
|
+
"partner"
|
|
53
|
+
]);
|
|
54
|
+
const createdSchema = z.object({
|
|
55
|
+
partyId: z.string(),
|
|
56
|
+
kind: partyKind,
|
|
57
|
+
displayName: z.string(),
|
|
58
|
+
roles: z.array(partyRole),
|
|
59
|
+
organizationId: z.string().optional()
|
|
60
|
+
});
|
|
61
|
+
const updatedSchema = z.object({
|
|
62
|
+
partyId: z.string(),
|
|
63
|
+
changed: z.array(z.string())
|
|
64
|
+
});
|
|
65
|
+
const roleChangedSchema = z.object({
|
|
66
|
+
partyId: z.string(),
|
|
67
|
+
role: partyRole
|
|
68
|
+
});
|
|
69
|
+
const userLinkedSchema = z.object({
|
|
70
|
+
partyId: z.string(),
|
|
71
|
+
userId: z.string()
|
|
72
|
+
});
|
|
73
|
+
const externalRefSchema = z.object({
|
|
74
|
+
partyId: z.string(),
|
|
75
|
+
sourceModel: z.string(),
|
|
76
|
+
sourceId: z.string()
|
|
77
|
+
});
|
|
78
|
+
const lifecycleSchema = z.object({ partyId: z.string() });
|
|
79
|
+
const mergedSchema = z.object({
|
|
80
|
+
sourceId: z.string(),
|
|
81
|
+
targetId: z.string(),
|
|
82
|
+
absorbedKeys: z.number().int().nonnegative()
|
|
83
|
+
});
|
|
84
|
+
const PartyCreated = definePartyEvent({
|
|
85
|
+
name: PARTY_EVENTS.CREATED,
|
|
86
|
+
description: "A new canonical party identity was created.",
|
|
87
|
+
zodSchema: createdSchema
|
|
88
|
+
});
|
|
89
|
+
const PartyUpdated = definePartyEvent({
|
|
90
|
+
name: PARTY_EVENTS.UPDATED,
|
|
91
|
+
description: "A party identity field changed.",
|
|
92
|
+
zodSchema: updatedSchema
|
|
93
|
+
});
|
|
94
|
+
const PartyRoleAdded = definePartyEvent({
|
|
95
|
+
name: PARTY_EVENTS.ROLE_ADDED,
|
|
96
|
+
description: "A role (customer/supplier/…) was granted to a party.",
|
|
97
|
+
zodSchema: roleChangedSchema
|
|
98
|
+
});
|
|
99
|
+
const PartyRoleRemoved = definePartyEvent({
|
|
100
|
+
name: PARTY_EVENTS.ROLE_REMOVED,
|
|
101
|
+
description: "A role was revoked from a party.",
|
|
102
|
+
zodSchema: roleChangedSchema
|
|
103
|
+
});
|
|
104
|
+
const PartyUserLinked = definePartyEvent({
|
|
105
|
+
name: PARTY_EVENTS.USER_LINKED,
|
|
106
|
+
description: "An auth user was linked to a party (1:many).",
|
|
107
|
+
zodSchema: userLinkedSchema
|
|
108
|
+
});
|
|
109
|
+
const PartyExternalRefAttached = definePartyEvent({
|
|
110
|
+
name: PARTY_EVENTS.EXTERNAL_REF_ATTACHED,
|
|
111
|
+
description: "An external-store reference was attached to a party (facet link).",
|
|
112
|
+
zodSchema: externalRefSchema
|
|
113
|
+
});
|
|
114
|
+
const PartyArchived = definePartyEvent({
|
|
115
|
+
name: PARTY_EVENTS.ARCHIVED,
|
|
116
|
+
description: "A party was archived.",
|
|
117
|
+
zodSchema: lifecycleSchema
|
|
118
|
+
});
|
|
119
|
+
const PartyRestored = definePartyEvent({
|
|
120
|
+
name: PARTY_EVENTS.RESTORED,
|
|
121
|
+
description: "An archived party was restored to active.",
|
|
122
|
+
zodSchema: lifecycleSchema
|
|
123
|
+
});
|
|
124
|
+
const PartyMerged = definePartyEvent({
|
|
125
|
+
name: PARTY_EVENTS.MERGED,
|
|
126
|
+
description: "A duplicate party was merged into a survivor; the source becomes a redirect.",
|
|
127
|
+
zodSchema: mergedSchema
|
|
128
|
+
});
|
|
129
|
+
const partyEventDefinitions = [
|
|
130
|
+
PartyCreated,
|
|
131
|
+
PartyUpdated,
|
|
132
|
+
PartyRoleAdded,
|
|
133
|
+
PartyRoleRemoved,
|
|
134
|
+
PartyUserLinked,
|
|
135
|
+
PartyExternalRefAttached,
|
|
136
|
+
PartyArchived,
|
|
137
|
+
PartyRestored,
|
|
138
|
+
PartyMerged
|
|
139
|
+
];
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/events/helpers.ts
|
|
142
|
+
/**
|
|
143
|
+
* `createEvent` — thin wrapper over primitives' `createEvent` that stamps
|
|
144
|
+
* `source: 'party'` and lifts `actorId`/`organizationId`/`correlationId` off the
|
|
145
|
+
* `PartyContext`. Use this instead of importing `createEvent` directly so the
|
|
146
|
+
* source stamp + meta stay consistent (PACKAGE_RULES §14).
|
|
147
|
+
*/
|
|
148
|
+
function createEvent(type, payload, ctx, meta) {
|
|
149
|
+
return createEvent$1(type, payload, {
|
|
150
|
+
source: "party",
|
|
151
|
+
...ctx?.actorId !== void 0 ? { actorId: String(ctx.actorId) } : {},
|
|
152
|
+
...ctx?.organizationId !== void 0 ? { organizationId: String(ctx.organizationId) } : {},
|
|
153
|
+
...ctx?.correlationId !== void 0 ? { correlationId: ctx.correlationId } : {},
|
|
154
|
+
...meta
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
158
|
+
//#region src/events/in-process-bus.ts
|
|
159
|
+
/**
|
|
160
|
+
* In-process event bus — default fallback when no `eventTransport` is given to
|
|
161
|
+
* `createParty`. Structurally identical to arc's `MemoryEventTransport`;
|
|
162
|
+
* pattern-matching delegates to primitives' `matchEventPattern` (PACKAGE_RULES
|
|
163
|
+
* §13). Handler errors are swallowed-and-logged — emission never blocks writes.
|
|
164
|
+
*/
|
|
165
|
+
var InProcessPartyBus = class {
|
|
166
|
+
name = "in-process-party";
|
|
167
|
+
subs = [];
|
|
168
|
+
logger;
|
|
169
|
+
constructor(options) {
|
|
170
|
+
this.logger = options?.logger;
|
|
171
|
+
}
|
|
172
|
+
async publish(event) {
|
|
173
|
+
const matching = this.subs.filter((s) => matchEventPattern(s.pattern, event.type));
|
|
174
|
+
for (const sub of matching) try {
|
|
175
|
+
await sub.handler(event);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
this.logger?.error?.(`[party] event handler error [${event.type}]`, { error: err instanceof Error ? err.message : String(err) });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async subscribe(pattern, handler) {
|
|
181
|
+
const sub = {
|
|
182
|
+
pattern,
|
|
183
|
+
handler
|
|
184
|
+
};
|
|
185
|
+
this.subs.push(sub);
|
|
186
|
+
return () => {
|
|
187
|
+
this.subs = this.subs.filter((s) => s !== sub);
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
async close() {
|
|
191
|
+
this.subs = [];
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/events/dispatch.ts
|
|
196
|
+
/** Context key carrying a post-commit publish queue (attached by `withTransaction`). */
|
|
197
|
+
const PENDING_EVENTS = Symbol.for("@classytic/party:pendingEvents");
|
|
198
|
+
var PartyEventDispatcher = class {
|
|
199
|
+
name;
|
|
200
|
+
transport;
|
|
201
|
+
outbox;
|
|
202
|
+
logger;
|
|
203
|
+
constructor(opts) {
|
|
204
|
+
this.transport = opts.transport;
|
|
205
|
+
this.outbox = opts.outbox;
|
|
206
|
+
this.logger = opts.logger;
|
|
207
|
+
this.name = opts.transport.name;
|
|
208
|
+
}
|
|
209
|
+
/** Raw publish — hosts and the unit-of-work flush use this. */
|
|
210
|
+
publish(event) {
|
|
211
|
+
return this.transport.publish(event);
|
|
212
|
+
}
|
|
213
|
+
subscribe(pattern, handler) {
|
|
214
|
+
return this.transport.subscribe(pattern, handler);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Route a domain event with full §P8 discipline. Reads the pending queue and
|
|
218
|
+
* session off `ctx`.
|
|
219
|
+
*/
|
|
220
|
+
async dispatch(event, ctx) {
|
|
221
|
+
if (this.outbox) try {
|
|
222
|
+
await this.outbox.save(event, ctx.session !== void 0 ? { session: ctx.session } : {});
|
|
223
|
+
} catch (err) {
|
|
224
|
+
this.logger?.error?.(`[party] outbox.save failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
const queue = ctx[PENDING_EVENTS];
|
|
228
|
+
if (Array.isArray(queue)) {
|
|
229
|
+
queue.push(event);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
await this.transport.publish(event);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
this.logger?.error?.(`[party] publish failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async close() {
|
|
239
|
+
await this.transport.close?.();
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
/**
|
|
243
|
+
* Drain a pending-events queue to the transport AFTER the owning transaction
|
|
244
|
+
* commits. Per-event failures are swallowed — durable delivery is the outbox's
|
|
245
|
+
* job (PACKAGE_RULES §P8.1).
|
|
246
|
+
*/
|
|
247
|
+
async function flushPending(dispatcher, queue, logger) {
|
|
248
|
+
for (const event of queue) try {
|
|
249
|
+
await dispatcher.publish(event);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
logger?.error?.(`[party] post-commit publish failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
//#endregion
|
|
255
|
+
export { InProcessPartyBus, PARTY_EVENTS, PENDING_EVENTS, PartyArchived, PartyCreated, PartyEventDispatcher, PartyExternalRefAttached, PartyMerged, PartyRestored, PartyRoleAdded, PartyRoleRemoved, PartyUpdated, PartyUserLinked, createEvent, flushPending, partyEventDefinitions };
|