@memberjunction/core 5.51.0 → 6.1.0-edge.1
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/LICENSE +7 -0
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +13 -2
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineRegistry.d.ts +14 -0
- package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
- package/dist/generic/baseEngineRegistry.js +32 -0
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +329 -26
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +788 -79
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +54 -17
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +133 -52
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityCompanion.d.ts +218 -0
- package/dist/generic/entityCompanion.d.ts.map +1 -0
- package/dist/generic/entityCompanion.js +170 -0
- package/dist/generic/entityCompanion.js.map +1 -0
- package/dist/generic/entityInfo.d.ts +146 -0
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +188 -0
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entitySavePlan.d.ts +199 -0
- package/dist/generic/entitySavePlan.d.ts.map +1 -0
- package/dist/generic/entitySavePlan.js +213 -0
- package/dist/generic/entitySavePlan.js.map +1 -0
- package/dist/generic/entityTransactionScope.d.ts +125 -0
- package/dist/generic/entityTransactionScope.d.ts.map +1 -0
- package/dist/generic/entityTransactionScope.js +115 -0
- package/dist/generic/entityTransactionScope.js.map +1 -0
- package/dist/generic/interfaces.d.ts +93 -35
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js +27 -0
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/providerBase.d.ts +13 -0
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +64 -5
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/relatedRecordBatchLoader.d.ts +39 -0
- package/dist/generic/relatedRecordBatchLoader.d.ts.map +1 -0
- package/dist/generic/relatedRecordBatchLoader.js +154 -0
- package/dist/generic/relatedRecordBatchLoader.js.map +1 -0
- package/dist/generic/relatedRecordCollection.d.ts +578 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -0
- package/dist/generic/relatedRecordCollection.js +1004 -0
- package/dist/generic/relatedRecordCollection.js.map +1 -0
- package/dist/generic/saveEntityGraphOperation.d.ts +148 -0
- package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -0
- package/dist/generic/saveEntityGraphOperation.js +157 -0
- package/dist/generic/saveEntityGraphOperation.js.map +1 -0
- package/dist/generic/securityInfo.d.ts +99 -1
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +88 -6
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts +21 -1
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +21 -6
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +31 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js.map +1 -1
- package/package.json +13 -13
- package/readme.md +159 -1
- package/dist/generic/runReport.d.ts +0 -25
- package/dist/generic/runReport.d.ts.map +0 -1
- package/dist/generic/runReport.js +0 -38
- package/dist/generic/runReport.js.map +0 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Entity Companions — named, serialisable side-channels attached to a `BaseEntity`.
|
|
3
|
+
*
|
|
4
|
+
* ## What a companion is
|
|
5
|
+
*
|
|
6
|
+
* A companion is a piece of state that belongs to a record but is **not one of its fields**, and
|
|
7
|
+
* that needs to travel with the record across the client/server boundary, participate in its
|
|
8
|
+
* validation, and contribute work to its save.
|
|
9
|
+
*
|
|
10
|
+
* The canonical example is a child collection — an order's lines, a journal entry's lines, a
|
|
11
|
+
* payment's allocations — but the abstraction is deliberately not about children. It is about
|
|
12
|
+
* "extra state that rides with the record", because MemberJunction has grown that concept ad hoc
|
|
13
|
+
* three times already:
|
|
14
|
+
*
|
|
15
|
+
* - `OldValues___` on the generated GraphQL save input (concurrency check payload)
|
|
16
|
+
* - `RestoreContext___` on the same input (restore lineage for the RecordChange row)
|
|
17
|
+
* - `EntityObjectJSON` in the TransactionGroup wire format (a whole record as opaque JSON)
|
|
18
|
+
*
|
|
19
|
+
* Each solved its own problem with its own bespoke slot, resolver branch and hand-written
|
|
20
|
+
* serialisation. Companions give the next such need — and the ones already in flight — one
|
|
21
|
+
* mechanism instead of a fourth bespoke field.
|
|
22
|
+
*
|
|
23
|
+
* ## What a companion is not
|
|
24
|
+
*
|
|
25
|
+
* A companion is **not** a way to smuggle arbitrary untyped data through the platform. Every
|
|
26
|
+
* companion has a stable {@link EntityCompanion.Name}, a typed wire shape, and explicit control
|
|
27
|
+
* over what it contributes to validation and save. Data with no owner and no contract belongs in a
|
|
28
|
+
* field, not here.
|
|
29
|
+
*
|
|
30
|
+
* ## Lifecycle
|
|
31
|
+
*
|
|
32
|
+
* ```text
|
|
33
|
+
* declare → subclass constructor calls RegisterCompanion() (usually via DeclareRelatedRecords())
|
|
34
|
+
* load → BaseEntity.Load()/LoadFromData() gives eager companions a chance to populate
|
|
35
|
+
* mutate → application code works with the typed companion API
|
|
36
|
+
* validate → BaseEntity.Validate()/ValidateAsync() fans out to every companion
|
|
37
|
+
* save → companions contribute nodes to the EntitySavePlan; the graph executes atomically
|
|
38
|
+
* transport→ Serialize() on the way out, Deserialize() on the way in
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* @module @memberjunction/core
|
|
42
|
+
*/
|
|
43
|
+
import { BaseEntity } from './baseEntity.js';
|
|
44
|
+
import { ValidationResult } from './entityInfo.js';
|
|
45
|
+
import type { EntitySavePlan } from './entitySavePlan.js';
|
|
46
|
+
import type { EntitySaveOptions } from './interfaces.js';
|
|
47
|
+
/**
|
|
48
|
+
* The serialized form of a single companion as it crosses the wire.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately minimal: a stable name plus an opaque JSON payload. Keeping the payload opaque is
|
|
51
|
+
* what lets a new companion type ship without touching the transport, the resolvers, or CodeGen.
|
|
52
|
+
*/
|
|
53
|
+
export type EntityCompanionPayload = {
|
|
54
|
+
/** The companion's stable {@link EntityCompanion.Name}. */
|
|
55
|
+
Name: string;
|
|
56
|
+
/** The companion's own serialized state, JSON-safe. */
|
|
57
|
+
Data: unknown;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* The property name under which companion payloads are carried inside the plain object produced by
|
|
61
|
+
* `BaseEntity.GetDataObject()` and consumed by `BaseEntity.LoadFromData()`.
|
|
62
|
+
*
|
|
63
|
+
* The trailing triple underscore follows the convention already set by `OldValues___` and
|
|
64
|
+
* `RestoreContext___`: it marks a reserved, non-field slot and makes accidental collision with a
|
|
65
|
+
* real column name effectively impossible.
|
|
66
|
+
*/
|
|
67
|
+
export declare const COMPANION_PAYLOAD_KEY = "Companions___";
|
|
68
|
+
/**
|
|
69
|
+
* Which direction a companion payload is travelling, which changes what the receiver must do with
|
|
70
|
+
* it.
|
|
71
|
+
*
|
|
72
|
+
* - `'request'` — the payload is a **caller's intent**, arriving at the tier that will persist it.
|
|
73
|
+
* Records that already exist must be loaded from the database first, so dirty tracking and
|
|
74
|
+
* old-value comparisons are computed against real stored values. Skipping that load makes an
|
|
75
|
+
* edited record look clean (old == new), its save is skipped, and the edit is silently lost.
|
|
76
|
+
* - `'result'` — the payload is **authoritative post-save state** coming back from the tier that
|
|
77
|
+
* just persisted it. It is adopted as-is: no queries, and the records land clean. Re-loading here
|
|
78
|
+
* would be one wasted round trip per record for data the sender already has.
|
|
79
|
+
*/
|
|
80
|
+
export type EntityCompanionDeserializeMode = 'request' | 'result';
|
|
81
|
+
/**
|
|
82
|
+
* Base class for all entity companions.
|
|
83
|
+
*
|
|
84
|
+
* Subclass this only when you need a genuinely new *kind* of companion. For the common case of a
|
|
85
|
+
* parent/child collection, use `RelatedRecordCollection<T>` via `BaseEntity.DeclareRelatedRecords()` rather than
|
|
86
|
+
* writing a companion by hand.
|
|
87
|
+
*
|
|
88
|
+
* @typeParam TWire - The JSON-safe shape this companion serializes to and from.
|
|
89
|
+
*
|
|
90
|
+
* @example Declaring a custom companion on a shared (client + server) entity subclass
|
|
91
|
+
* ```typescript
|
|
92
|
+
* class ApprovalTrailCompanion extends EntityCompanion<ApprovalWire> {
|
|
93
|
+
* public readonly Name = 'ApprovalTrail';
|
|
94
|
+
*
|
|
95
|
+
* public async Serialize(): Promise<ApprovalWire | null> {
|
|
96
|
+
* return this.entries.length ? { Entries: this.entries } : null;
|
|
97
|
+
* }
|
|
98
|
+
*
|
|
99
|
+
* public async Deserialize(data: ApprovalWire): Promise<void> {
|
|
100
|
+
* this.entries = data.Entries ?? [];
|
|
101
|
+
* }
|
|
102
|
+
* }
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
export declare abstract class EntityCompanion<TWire = unknown> {
|
|
106
|
+
/**
|
|
107
|
+
* The entity this companion is attached to. Set by {@link BaseEntity.RegisterCompanion}.
|
|
108
|
+
*/
|
|
109
|
+
readonly Owner: BaseEntity;
|
|
110
|
+
/**
|
|
111
|
+
* @param owner - The entity this companion belongs to.
|
|
112
|
+
*/
|
|
113
|
+
constructor(owner: BaseEntity);
|
|
114
|
+
/**
|
|
115
|
+
* Stable identifier for this companion, unique within its owning entity.
|
|
116
|
+
*
|
|
117
|
+
* This is the wire key: it appears in serialized payloads and is how the receiving tier finds
|
|
118
|
+
* the companion to deserialize into. **Treat it as a published contract** — renaming it breaks
|
|
119
|
+
* in-flight payloads and any persisted snapshot that captured them.
|
|
120
|
+
*/
|
|
121
|
+
abstract readonly Name: string;
|
|
122
|
+
/**
|
|
123
|
+
* Produces this companion's JSON-safe state for transport, or `null` when it has nothing to
|
|
124
|
+
* send.
|
|
125
|
+
*
|
|
126
|
+
* Returning `null` keeps the companion out of the payload entirely, which matters: a save that
|
|
127
|
+
* touches only header fields should not ship an empty children array and pay for it on every
|
|
128
|
+
* request.
|
|
129
|
+
*
|
|
130
|
+
* @returns The wire payload, or `null` to omit this companion.
|
|
131
|
+
*/
|
|
132
|
+
abstract Serialize(): Promise<TWire | null>;
|
|
133
|
+
/**
|
|
134
|
+
* Restores this companion's state from a wire payload produced by {@link Serialize} on the
|
|
135
|
+
* other tier.
|
|
136
|
+
*
|
|
137
|
+
* Implementations must be tolerant of payloads written by an older version of themselves —
|
|
138
|
+
* a companion is a wire contract, and rolling deploys mean both versions run at once.
|
|
139
|
+
*
|
|
140
|
+
* @param data - The payload previously produced by {@link Serialize}.
|
|
141
|
+
* @param mode - Whether this payload is an inbound request or an authoritative result. See
|
|
142
|
+
* {@link EntityCompanionDeserializeMode}; the distinction is load-bearing.
|
|
143
|
+
*/
|
|
144
|
+
abstract Deserialize(data: TWire, mode: EntityCompanionDeserializeMode): Promise<void>;
|
|
145
|
+
/**
|
|
146
|
+
* Whether this companion holds unsaved changes.
|
|
147
|
+
*
|
|
148
|
+
* Rolled up into `BaseEntity.Dirty`, which is what makes a save actually happen when only the
|
|
149
|
+
* companion changed. Before companions existed, a clean parent with three new children returned
|
|
150
|
+
* early from `Save()` and silently persisted nothing.
|
|
151
|
+
*
|
|
152
|
+
* @returns True when saving would produce work.
|
|
153
|
+
*/
|
|
154
|
+
get Dirty(): boolean;
|
|
155
|
+
/**
|
|
156
|
+
* Synchronous, in-memory validation contributed by this companion.
|
|
157
|
+
*
|
|
158
|
+
* Runs as part of the owner's `Validate()`, **before any write**, over the companion's complete
|
|
159
|
+
* state — including pending removals. That ordering is what lets cross-child invariants such as
|
|
160
|
+
* "debits must equal credits" be enforced correctly rather than after half the graph has landed.
|
|
161
|
+
*
|
|
162
|
+
* Push errors onto `result.Errors` and set `result.Success = false` to fail the save.
|
|
163
|
+
*
|
|
164
|
+
* @param _result - The accumulating validation result to contribute to.
|
|
165
|
+
*/
|
|
166
|
+
Validate(_result: ValidationResult): void;
|
|
167
|
+
/**
|
|
168
|
+
* Asynchronous validation contributed by this companion — anything that needs a round trip.
|
|
169
|
+
*
|
|
170
|
+
* @remarks
|
|
171
|
+
* Unlike an entity's own `ValidateAsync()`, this is **not** governed by
|
|
172
|
+
* `BaseEntity.DefaultSkipAsyncValidation`. That flag exists so an entity can opt out of its own
|
|
173
|
+
* expensive async rules; applying it to companions silently skipped cross-child invariants,
|
|
174
|
+
* which is how `OrderEntityServer.ValidateAsync` came to be dead code on every save. Companion
|
|
175
|
+
* validation runs whenever the companion is dirty.
|
|
176
|
+
*
|
|
177
|
+
* @param _result - The accumulating validation result to contribute to.
|
|
178
|
+
*/
|
|
179
|
+
ValidateAsync(_result: ValidationResult): Promise<void>;
|
|
180
|
+
/**
|
|
181
|
+
* Contributes this companion's work to the owner's save plan.
|
|
182
|
+
*
|
|
183
|
+
* Called after the owner's own node has been added, so implementations may assume the parent
|
|
184
|
+
* node exists and order their nodes relative to it. Add nothing when there is no work — an
|
|
185
|
+
* empty contribution keeps the save on the fast single-row path.
|
|
186
|
+
*
|
|
187
|
+
* @param _plan - The plan being assembled for this unit of work.
|
|
188
|
+
* @param _options - The caller's save options. Implementations that decide what counts as work
|
|
189
|
+
* (skipping clean children, most importantly) must honor flags such as
|
|
190
|
+
* `IgnoreDirtyState` that demand a full write-out.
|
|
191
|
+
*/
|
|
192
|
+
ContributeSaveWork(_plan: EntitySavePlan, _options?: EntitySaveOptions): void;
|
|
193
|
+
/**
|
|
194
|
+
* Contributes this companion's work to the owner's delete plan.
|
|
195
|
+
*
|
|
196
|
+
* Called before the owner's own node, because children must generally be removed before the
|
|
197
|
+
* parent row they point at. Implementations that rely on database-level cascade delete should
|
|
198
|
+
* contribute nothing.
|
|
199
|
+
*
|
|
200
|
+
* @param _plan - The plan being assembled for this unit of work.
|
|
201
|
+
*/
|
|
202
|
+
ContributeDeleteWork(_plan: EntitySavePlan): void;
|
|
203
|
+
/**
|
|
204
|
+
* Populates this companion from the database, when it is configured to load eagerly.
|
|
205
|
+
*
|
|
206
|
+
* Called by `BaseEntity.Load()` after the record's own fields are populated. **Never** called
|
|
207
|
+
* from `LoadFromData()` — that is the row-materialization path for
|
|
208
|
+
* `RunView(ResultType:'entity_object')`, so loading children there turns one view into an N+1
|
|
209
|
+
* storm. Set-oriented eager loading is handled by `RunView`'s batched child loading instead.
|
|
210
|
+
*/
|
|
211
|
+
LoadEager(): Promise<void>;
|
|
212
|
+
/**
|
|
213
|
+
* Resets the companion to its post-save state — clearing pending removals, rebasing dirty
|
|
214
|
+
* tracking, and so on. Called after the graph commits successfully.
|
|
215
|
+
*/
|
|
216
|
+
AcceptChanges(): void;
|
|
217
|
+
}
|
|
218
|
+
//# sourceMappingURL=entityCompanion.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entityCompanion.d.ts","sourceRoot":"","sources":["../../src/generic/entityCompanion.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAChD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACvD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAEtD;;;;;GAKG;AACH,MAAM,MAAM,sBAAsB,GAAG;IACjC,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAC;IACb,uDAAuD;IACvD,IAAI,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,kBAAkB,CAAC;AAErD;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,8BAA8B,GAAG,SAAS,GAAG,QAAQ,CAAC;AAElE;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,8BAAsB,eAAe,CAAC,KAAK,GAAG,OAAO;IACjD;;OAEG;IACH,SAAgB,KAAK,EAAE,UAAU,CAAC;IAElC;;OAEG;gBACS,KAAK,EAAE,UAAU;IAI7B;;;;;;OAMG;IACH,kBAAyB,IAAI,EAAE,MAAM,CAAC;IAEtC;;;;;;;;;OASG;aACa,SAAS,IAAI,OAAO,CAAC,KAAK,GAAG,IAAI,CAAC;IAElD;;;;;;;;;;OAUG;aACa,WAAW,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,8BAA8B,GAAG,OAAO,CAAC,IAAI,CAAC;IAE7F;;;;;;;;OAQG;IACH,IAAW,KAAK,IAAI,OAAO,CAE1B;IAED;;;;;;;;;;OAUG;IACI,QAAQ,CAAC,OAAO,EAAE,gBAAgB,GAAG,IAAI;IAIhD;;;;;;;;;;;OAWG;IACU,aAAa,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;IAIpE;;;;;;;;;;;OAWG;IACI,kBAAkB,CAAC,KAAK,EAAE,cAAc,EAAE,QAAQ,CAAC,EAAE,iBAAiB,GAAG,IAAI;IAIpF;;;;;;;;OAQG;IACI,oBAAoB,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI;IAIxD;;;;;;;OAOG;IACU,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC;IAIvC;;;OAGG;IACI,aAAa,IAAI,IAAI;CAG/B"}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Entity Companions — named, serialisable side-channels attached to a `BaseEntity`.
|
|
3
|
+
*
|
|
4
|
+
* ## What a companion is
|
|
5
|
+
*
|
|
6
|
+
* A companion is a piece of state that belongs to a record but is **not one of its fields**, and
|
|
7
|
+
* that needs to travel with the record across the client/server boundary, participate in its
|
|
8
|
+
* validation, and contribute work to its save.
|
|
9
|
+
*
|
|
10
|
+
* The canonical example is a child collection — an order's lines, a journal entry's lines, a
|
|
11
|
+
* payment's allocations — but the abstraction is deliberately not about children. It is about
|
|
12
|
+
* "extra state that rides with the record", because MemberJunction has grown that concept ad hoc
|
|
13
|
+
* three times already:
|
|
14
|
+
*
|
|
15
|
+
* - `OldValues___` on the generated GraphQL save input (concurrency check payload)
|
|
16
|
+
* - `RestoreContext___` on the same input (restore lineage for the RecordChange row)
|
|
17
|
+
* - `EntityObjectJSON` in the TransactionGroup wire format (a whole record as opaque JSON)
|
|
18
|
+
*
|
|
19
|
+
* Each solved its own problem with its own bespoke slot, resolver branch and hand-written
|
|
20
|
+
* serialisation. Companions give the next such need — and the ones already in flight — one
|
|
21
|
+
* mechanism instead of a fourth bespoke field.
|
|
22
|
+
*
|
|
23
|
+
* ## What a companion is not
|
|
24
|
+
*
|
|
25
|
+
* A companion is **not** a way to smuggle arbitrary untyped data through the platform. Every
|
|
26
|
+
* companion has a stable {@link EntityCompanion.Name}, a typed wire shape, and explicit control
|
|
27
|
+
* over what it contributes to validation and save. Data with no owner and no contract belongs in a
|
|
28
|
+
* field, not here.
|
|
29
|
+
*
|
|
30
|
+
* ## Lifecycle
|
|
31
|
+
*
|
|
32
|
+
* ```text
|
|
33
|
+
* declare → subclass constructor calls RegisterCompanion() (usually via DeclareRelatedRecords())
|
|
34
|
+
* load → BaseEntity.Load()/LoadFromData() gives eager companions a chance to populate
|
|
35
|
+
* mutate → application code works with the typed companion API
|
|
36
|
+
* validate → BaseEntity.Validate()/ValidateAsync() fans out to every companion
|
|
37
|
+
* save → companions contribute nodes to the EntitySavePlan; the graph executes atomically
|
|
38
|
+
* transport→ Serialize() on the way out, Deserialize() on the way in
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* @module @memberjunction/core
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* The property name under which companion payloads are carried inside the plain object produced by
|
|
45
|
+
* `BaseEntity.GetDataObject()` and consumed by `BaseEntity.LoadFromData()`.
|
|
46
|
+
*
|
|
47
|
+
* The trailing triple underscore follows the convention already set by `OldValues___` and
|
|
48
|
+
* `RestoreContext___`: it marks a reserved, non-field slot and makes accidental collision with a
|
|
49
|
+
* real column name effectively impossible.
|
|
50
|
+
*/
|
|
51
|
+
export const COMPANION_PAYLOAD_KEY = 'Companions___';
|
|
52
|
+
/**
|
|
53
|
+
* Base class for all entity companions.
|
|
54
|
+
*
|
|
55
|
+
* Subclass this only when you need a genuinely new *kind* of companion. For the common case of a
|
|
56
|
+
* parent/child collection, use `RelatedRecordCollection<T>` via `BaseEntity.DeclareRelatedRecords()` rather than
|
|
57
|
+
* writing a companion by hand.
|
|
58
|
+
*
|
|
59
|
+
* @typeParam TWire - The JSON-safe shape this companion serializes to and from.
|
|
60
|
+
*
|
|
61
|
+
* @example Declaring a custom companion on a shared (client + server) entity subclass
|
|
62
|
+
* ```typescript
|
|
63
|
+
* class ApprovalTrailCompanion extends EntityCompanion<ApprovalWire> {
|
|
64
|
+
* public readonly Name = 'ApprovalTrail';
|
|
65
|
+
*
|
|
66
|
+
* public async Serialize(): Promise<ApprovalWire | null> {
|
|
67
|
+
* return this.entries.length ? { Entries: this.entries } : null;
|
|
68
|
+
* }
|
|
69
|
+
*
|
|
70
|
+
* public async Deserialize(data: ApprovalWire): Promise<void> {
|
|
71
|
+
* this.entries = data.Entries ?? [];
|
|
72
|
+
* }
|
|
73
|
+
* }
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export class EntityCompanion {
|
|
77
|
+
/**
|
|
78
|
+
* @param owner - The entity this companion belongs to.
|
|
79
|
+
*/
|
|
80
|
+
constructor(owner) {
|
|
81
|
+
this.Owner = owner;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether this companion holds unsaved changes.
|
|
85
|
+
*
|
|
86
|
+
* Rolled up into `BaseEntity.Dirty`, which is what makes a save actually happen when only the
|
|
87
|
+
* companion changed. Before companions existed, a clean parent with three new children returned
|
|
88
|
+
* early from `Save()` and silently persisted nothing.
|
|
89
|
+
*
|
|
90
|
+
* @returns True when saving would produce work.
|
|
91
|
+
*/
|
|
92
|
+
get Dirty() {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Synchronous, in-memory validation contributed by this companion.
|
|
97
|
+
*
|
|
98
|
+
* Runs as part of the owner's `Validate()`, **before any write**, over the companion's complete
|
|
99
|
+
* state — including pending removals. That ordering is what lets cross-child invariants such as
|
|
100
|
+
* "debits must equal credits" be enforced correctly rather than after half the graph has landed.
|
|
101
|
+
*
|
|
102
|
+
* Push errors onto `result.Errors` and set `result.Success = false` to fail the save.
|
|
103
|
+
*
|
|
104
|
+
* @param _result - The accumulating validation result to contribute to.
|
|
105
|
+
*/
|
|
106
|
+
Validate(_result) {
|
|
107
|
+
/* no-op by default */
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Asynchronous validation contributed by this companion — anything that needs a round trip.
|
|
111
|
+
*
|
|
112
|
+
* @remarks
|
|
113
|
+
* Unlike an entity's own `ValidateAsync()`, this is **not** governed by
|
|
114
|
+
* `BaseEntity.DefaultSkipAsyncValidation`. That flag exists so an entity can opt out of its own
|
|
115
|
+
* expensive async rules; applying it to companions silently skipped cross-child invariants,
|
|
116
|
+
* which is how `OrderEntityServer.ValidateAsync` came to be dead code on every save. Companion
|
|
117
|
+
* validation runs whenever the companion is dirty.
|
|
118
|
+
*
|
|
119
|
+
* @param _result - The accumulating validation result to contribute to.
|
|
120
|
+
*/
|
|
121
|
+
async ValidateAsync(_result) {
|
|
122
|
+
/* no-op by default */
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Contributes this companion's work to the owner's save plan.
|
|
126
|
+
*
|
|
127
|
+
* Called after the owner's own node has been added, so implementations may assume the parent
|
|
128
|
+
* node exists and order their nodes relative to it. Add nothing when there is no work — an
|
|
129
|
+
* empty contribution keeps the save on the fast single-row path.
|
|
130
|
+
*
|
|
131
|
+
* @param _plan - The plan being assembled for this unit of work.
|
|
132
|
+
* @param _options - The caller's save options. Implementations that decide what counts as work
|
|
133
|
+
* (skipping clean children, most importantly) must honor flags such as
|
|
134
|
+
* `IgnoreDirtyState` that demand a full write-out.
|
|
135
|
+
*/
|
|
136
|
+
ContributeSaveWork(_plan, _options) {
|
|
137
|
+
/* no-op by default */
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Contributes this companion's work to the owner's delete plan.
|
|
141
|
+
*
|
|
142
|
+
* Called before the owner's own node, because children must generally be removed before the
|
|
143
|
+
* parent row they point at. Implementations that rely on database-level cascade delete should
|
|
144
|
+
* contribute nothing.
|
|
145
|
+
*
|
|
146
|
+
* @param _plan - The plan being assembled for this unit of work.
|
|
147
|
+
*/
|
|
148
|
+
ContributeDeleteWork(_plan) {
|
|
149
|
+
/* no-op by default */
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Populates this companion from the database, when it is configured to load eagerly.
|
|
153
|
+
*
|
|
154
|
+
* Called by `BaseEntity.Load()` after the record's own fields are populated. **Never** called
|
|
155
|
+
* from `LoadFromData()` — that is the row-materialization path for
|
|
156
|
+
* `RunView(ResultType:'entity_object')`, so loading children there turns one view into an N+1
|
|
157
|
+
* storm. Set-oriented eager loading is handled by `RunView`'s batched child loading instead.
|
|
158
|
+
*/
|
|
159
|
+
async LoadEager() {
|
|
160
|
+
/* no-op by default */
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Resets the companion to its post-save state — clearing pending removals, rebasing dirty
|
|
164
|
+
* tracking, and so on. Called after the graph commits successfully.
|
|
165
|
+
*/
|
|
166
|
+
AcceptChanges() {
|
|
167
|
+
/* no-op by default */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
//# sourceMappingURL=entityCompanion.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entityCompanion.js","sourceRoot":"","sources":["../../src/generic/entityCompanion.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAoBH;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,eAAe,CAAC;AAgBrD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,OAAgB,eAAe;IAMjC;;OAEG;IACH,YAAY,KAAiB;QACzB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACvB,CAAC;IAoCD;;;;;;;;OAQG;IACH,IAAW,KAAK;QACZ,OAAO,KAAK,CAAC;IACjB,CAAC;IAED;;;;;;;;;;OAUG;IACI,QAAQ,CAAC,OAAyB;QACrC,sBAAsB;IAC1B,CAAC;IAED;;;;;;;;;;;OAWG;IACI,KAAK,CAAC,aAAa,CAAC,OAAyB;QAChD,sBAAsB;IAC1B,CAAC;IAED;;;;;;;;;;;OAWG;IACI,kBAAkB,CAAC,KAAqB,EAAE,QAA4B;QACzE,sBAAsB;IAC1B,CAAC;IAED;;;;;;;;OAQG;IACI,oBAAoB,CAAC,KAAqB;QAC7C,sBAAsB;IAC1B,CAAC;IAED;;;;;;;OAOG;IACI,KAAK,CAAC,SAAS;QAClB,sBAAsB;IAC1B,CAAC;IAED;;;OAGG;IACI,aAAa;QAChB,sBAAsB;IAC1B,CAAC;CACJ"}
|
|
@@ -73,6 +73,27 @@ export declare class EntityRelationshipInfo extends BaseInfo {
|
|
|
73
73
|
* * Description: Indicates whether this relationship should be automatically updated by CodeGen. When set to 0, the record will not be modified by CodeGen. Defaults to true.
|
|
74
74
|
*/
|
|
75
75
|
AutoUpdateFromSchema: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* * Field Name: RelatedRecordCollection
|
|
78
|
+
* * SQL Data Type: nvarchar(MAX), nullable
|
|
79
|
+
*
|
|
80
|
+
* Optional JSON policy object declaring this relationship as a first-class **related-record
|
|
81
|
+
* collection** — a set of records that load, validate and persist as one unit with the parent.
|
|
82
|
+
* When non-null, CodeGen emits a typed `DeclareRelatedRecords(...)` declaration onto the
|
|
83
|
+
* generated entity subclass, so applications no longer hand-write it.
|
|
84
|
+
*
|
|
85
|
+
* Shape is `IRelatedRecordCollectionConfig`: `Name` (the generated property name, e.g. `Lines`),
|
|
86
|
+
* `Load`, `OnRemove`, `OrderBy`, `Sequence` and `ClearAfterSave`. {@link RelatedEntity} and
|
|
87
|
+
* {@link RelatedEntityJoinField} are deliberately **not** repeated inside the JSON — they are
|
|
88
|
+
* already columns on this row, and duplicating them would create two sources of truth with the
|
|
89
|
+
* JSON copy winning silently.
|
|
90
|
+
*
|
|
91
|
+
* `null` (the default, and every pre-6.2 row) means the relationship is not a declared
|
|
92
|
+
* collection: nothing is generated and nothing loads eagerly.
|
|
93
|
+
*
|
|
94
|
+
* @see guides/TRANSACTIONS_AND_BATCHING_GUIDE.md
|
|
95
|
+
*/
|
|
96
|
+
RelatedRecordCollection: string;
|
|
76
97
|
Entity: string;
|
|
77
98
|
EntityBaseTable: string;
|
|
78
99
|
EntityBaseView: string;
|
|
@@ -954,6 +975,57 @@ export declare class EntityInfo extends BaseInfo {
|
|
|
954
975
|
* Whether the base view is generated by CodeGen (true) or manually created (false)
|
|
955
976
|
*/
|
|
956
977
|
BaseViewGenerated: boolean;
|
|
978
|
+
/**
|
|
979
|
+
* When set, CodeGen generates the entity's full base view under THIS name instead of
|
|
980
|
+
* {@link BaseView}, and the application owns `BaseView` — which is expected to wrap it:
|
|
981
|
+
*
|
|
982
|
+
* ```sql
|
|
983
|
+
* CREATE VIEW vwOrderHeaders AS
|
|
984
|
+
* SELECT g.*, CASE WHEN ... END AS IsOverdue
|
|
985
|
+
* FROM vwOrderHeadersGenerated g
|
|
986
|
+
* ```
|
|
987
|
+
*
|
|
988
|
+
* This is how an entity gets a custom base view WITHOUT inheriting the generated SQL. With
|
|
989
|
+
* `BaseViewGenerated = 0` alone the application takes over the whole view — every related-entity
|
|
990
|
+
* display join, the geo join, the recursive root-ID apply — and must hand-maintain it forever;
|
|
991
|
+
* a foreign key added later then silently never appears, because nothing regenerates the join.
|
|
992
|
+
* Naming an inner view keeps all of that regenerating underneath a thin, reviewable custom layer.
|
|
993
|
+
*
|
|
994
|
+
* NULL (the default, and every pre-existing entity) preserves the original behaviour exactly:
|
|
995
|
+
* `BaseViewGenerated` alone decides whether CodeGen writes `BaseView`, and there is no second view.
|
|
996
|
+
*
|
|
997
|
+
* `BaseView` remains the public surface either way — field discovery, permissions and the
|
|
998
|
+
* generated CRUD procedures all target it, so a column added by the custom layer becomes a
|
|
999
|
+
* first-class virtual `EntityField`.
|
|
1000
|
+
*/
|
|
1001
|
+
GeneratedBaseViewName: string;
|
|
1002
|
+
/**
|
|
1003
|
+
* The view CodeGen actually WRITES for this entity.
|
|
1004
|
+
*
|
|
1005
|
+
* Normally {@link BaseView}. When {@link GeneratedBaseViewName} is set, the generated SQL goes
|
|
1006
|
+
* there instead and `BaseView` belongs to the application, which layers over it — see
|
|
1007
|
+
* {@link HasLayeredBaseView}.
|
|
1008
|
+
*
|
|
1009
|
+
* Resolved in ONE place because the two names must never drift: several call sites decide where
|
|
1010
|
+
* to write the view, what to call the emitted file, and which object to refresh, and a
|
|
1011
|
+
* disagreement between any two of them produces a view that exists under a name nothing reads.
|
|
1012
|
+
*
|
|
1013
|
+
* Derived FROM {@link HasLayeredBaseView} rather than re-testing `GeneratedBaseViewName`, so the
|
|
1014
|
+
* two getters cannot disagree by construction. Testing the raw column here would diverge on a
|
|
1015
|
+
* name that differs from `BaseView` only by case: `HasLayeredBaseView` would say "not layered"
|
|
1016
|
+
* (it compares case-insensitively, because SQL Server object names are) while this getter
|
|
1017
|
+
* returned the differently-cased string — leaving CodeGen writing to one object while every
|
|
1018
|
+
* layering-gated code path believed there was no second view at all.
|
|
1019
|
+
*/
|
|
1020
|
+
get GeneratedViewName(): string;
|
|
1021
|
+
/**
|
|
1022
|
+
* True when this entity has a generated inner view with an application-owned `BaseView` on top.
|
|
1023
|
+
*
|
|
1024
|
+
* In that arrangement CodeGen still generates everything — related-entity display fields, geo
|
|
1025
|
+
* columns, recursive root-ID columns — into {@link GeneratedViewName}, so the custom layer stays
|
|
1026
|
+
* thin and does not go stale when the schema gains a foreign key.
|
|
1027
|
+
*/
|
|
1028
|
+
get HasLayeredBaseView(): boolean;
|
|
957
1029
|
/**
|
|
958
1030
|
* Database schema that contains this entity's table and view
|
|
959
1031
|
*/
|
|
@@ -1038,6 +1110,47 @@ export declare class EntityInfo extends BaseInfo {
|
|
|
1038
1110
|
* Global flag controlling whether records can be deleted via API
|
|
1039
1111
|
*/
|
|
1040
1112
|
AllowDeleteAPI: boolean;
|
|
1113
|
+
/**
|
|
1114
|
+
* Whether rows may be INSERTed by SQL that does not go through {@link BaseEntity}.Save() —
|
|
1115
|
+
* bulk loads, ETL/integration sync, or rows created as a side effect of a stored procedure.
|
|
1116
|
+
*
|
|
1117
|
+
* `false` (the default, and every entity that has not opted in) means all inserts are expected
|
|
1118
|
+
* to flow through `BaseEntity`, which is the only path where record-change tracking, entity
|
|
1119
|
+
* actions, validation and cache invalidation actually run.
|
|
1120
|
+
*
|
|
1121
|
+
* **This DECLARES intent; it enforces nothing.** No constraint, trigger or grant prevents
|
|
1122
|
+
* anyone from executing SQL. It exists so the code paths and tooling that *choose* to honour
|
|
1123
|
+
* the platform contract — bulk/ETL and integration sync, record-set processing, and generators
|
|
1124
|
+
* or agents authoring SQL — have one authoritative answer instead of tribal knowledge.
|
|
1125
|
+
*
|
|
1126
|
+
* A database CHECK requires {@link TrackRecordChanges} and {@link TrustServerCacheCompletely}
|
|
1127
|
+
* to both be `false` when this is set, because a direct insert produces neither an audit row
|
|
1128
|
+
* nor a cache-invalidation event — leaving either on yields an audit trail that looks complete
|
|
1129
|
+
* but is not, and a server cache that serves stale rows indefinitely.
|
|
1130
|
+
*/
|
|
1131
|
+
AllowDirectSQLInsert: boolean;
|
|
1132
|
+
/**
|
|
1133
|
+
* Whether rows may be UPDATEd by SQL that does not go through {@link BaseEntity}.Save() —
|
|
1134
|
+
* bulk backfills, integration sync, or maintenance routines.
|
|
1135
|
+
*
|
|
1136
|
+
* `false` (the default) means all updates are expected to flow through `BaseEntity`. See
|
|
1137
|
+
* {@link AllowDirectSQLInsert} for the full rationale, the "declares, does not enforce"
|
|
1138
|
+
* caveat, and the `TrackRecordChanges` / `TrustServerCacheCompletely` requirement.
|
|
1139
|
+
*/
|
|
1140
|
+
AllowDirectSQLUpdate: boolean;
|
|
1141
|
+
/**
|
|
1142
|
+
* Whether rows may be DELETEd by SQL that does not go through {@link BaseEntity}.Delete() —
|
|
1143
|
+
* purge and retention routines, or integration sync reconciling against a remote source.
|
|
1144
|
+
*
|
|
1145
|
+
* `false` (the default) means all deletes are expected to flow through `BaseEntity`. See
|
|
1146
|
+
* {@link AllowDirectSQLInsert} for the full rationale and the `TrackRecordChanges` /
|
|
1147
|
+
* {@link TrustServerCacheCompletely} requirement.
|
|
1148
|
+
*
|
|
1149
|
+
* Additionally requires {@link DeleteType} to be `'Hard'`: a direct `DELETE` removes the row
|
|
1150
|
+
* outright rather than setting `DeletedAt`, so sanctioning it on a soft-delete entity would
|
|
1151
|
+
* quietly defeat soft delete. A database CHECK refuses the combination.
|
|
1152
|
+
*/
|
|
1153
|
+
AllowDirectSQLDelete: boolean;
|
|
1041
1154
|
/**
|
|
1042
1155
|
* If true, uses a custom resolver for GraphQL operations instead of standard CRUD
|
|
1043
1156
|
*/
|
|
@@ -1483,12 +1596,45 @@ export declare class EntityInfo extends BaseInfo {
|
|
|
1483
1596
|
GetUserRowLevelSecurityInfo(user: UserInfo, type: EntityPermissionType): RowLevelSecurityFilterInfo[];
|
|
1484
1597
|
/**
|
|
1485
1598
|
* Generates a where clause for SQL filtering for a given entity for a given user and permission type. If there is no RLS for a given entity or the user is exempt from RLS for the entity, a blank string is returned.
|
|
1599
|
+
*
|
|
1600
|
+
* @deprecated ROLE RLS ONLY — this method is subject to the role-RLS exemption and
|
|
1601
|
+
* silently omits API-key row filters, so a caller reaching for this familiar name gets
|
|
1602
|
+
* a clause that is fail-open for filtered API-key sessions. Use
|
|
1603
|
+
* {@link GetEffectiveRowFilterWhereClause}, which composes every filter layer. A repo
|
|
1604
|
+
* test asserts no non-test caller exists outside that method.
|
|
1486
1605
|
* @param user
|
|
1487
1606
|
* @param type
|
|
1488
1607
|
* @param returnPrefix
|
|
1489
1608
|
* @returns
|
|
1490
1609
|
*/
|
|
1491
1610
|
GetUserRowLevelSecurityWhereClause(user: UserInfo, type: EntityPermissionType, returnPrefix: string): string;
|
|
1611
|
+
/**
|
|
1612
|
+
* The EFFECTIVE row-filter clause for a user + permission type: role RLS (subject to the
|
|
1613
|
+
* role exemption) AND the API-key row filters carried on the session (NOT subject to the
|
|
1614
|
+
* role exemption — a key ceiling exists precisely to bind principals whose roles are
|
|
1615
|
+
* unrestricted; see UserExemptFromRowLevelSecurity, which exempts off the mere presence
|
|
1616
|
+
* of a filter-less permission row). Composition: OR within the role layer (roles are
|
|
1617
|
+
* additive), AND across layers (no layer can widen another). Returns '' only when no
|
|
1618
|
+
* layer contributes.
|
|
1619
|
+
*
|
|
1620
|
+
* This is THE method every enforcement point must call. Deterministic by construction —
|
|
1621
|
+
* key-filter clauses render in FilterID order and list tokens sort their elements —
|
|
1622
|
+
* because the identical clause participates in the RunView cache fingerprint (INV-2):
|
|
1623
|
+
* any nondeterminism silently splits or merges cache slots.
|
|
1624
|
+
*
|
|
1625
|
+
* The application-ceiling layer (APIApplicationScope.RowFilterID) is deferred to v2 by
|
|
1626
|
+
* design decision; the column ships unused and the term composes here when it lands.
|
|
1627
|
+
*/
|
|
1628
|
+
GetEffectiveRowFilterWhereClause(user: UserInfo, type: EntityPermissionType, returnPrefix: string): string;
|
|
1629
|
+
/**
|
|
1630
|
+
* Resolves the API-key row-filter clause for this entity + permission type from the
|
|
1631
|
+
* bindings stamped on the session UserInfo. Multiple matching bindings AND together
|
|
1632
|
+
* (most-restrictive-wins — an unfiltered grant elsewhere cannot cancel a filter).
|
|
1633
|
+
* Fail-closed at every edge: a binding whose filter is missing from metadata (dangling
|
|
1634
|
+
* or unloaded) contributes `(1=0)`, and unresolved tokens inside a filter collapse it
|
|
1635
|
+
* to `(1=0)` via the match-nothing markup mode.
|
|
1636
|
+
*/
|
|
1637
|
+
private getAPIKeyRowFilterClause;
|
|
1492
1638
|
/**
|
|
1493
1639
|
* Returns a RunViewParams object that is setup to filter the related entity for the provided record
|
|
1494
1640
|
* @param record
|