@memberjunction/lists 0.0.1 → 5.37.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/README.md +292 -28
- package/dist/AudienceResolver.d.ts +22 -0
- package/dist/AudienceResolver.d.ts.map +1 -0
- package/dist/AudienceResolver.js +25 -0
- package/dist/AudienceResolver.js.map +1 -0
- package/dist/ListOperations.d.ts +215 -0
- package/dist/ListOperations.d.ts.map +1 -0
- package/dist/ListOperations.js +829 -0
- package/dist/ListOperations.js.map +1 -0
- package/dist/ListSharing.d.ts +157 -0
- package/dist/ListSharing.d.ts.map +1 -0
- package/dist/ListSharing.js +568 -0
- package/dist/ListSharing.js.map +1 -0
- package/dist/deltaToken.d.ts +47 -0
- package/dist/deltaToken.d.ts.map +1 -0
- package/dist/deltaToken.js +123 -0
- package/dist/deltaToken.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/package.json +32 -7
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
import { CompositeKey, LogError, LogStatus, Metadata, RunView, } from '@memberjunction/core';
|
|
2
|
+
import { ComputeSourceSignature, DeltaTokenVerificationError, SignDeltaToken, VerifyDeltaToken, } from './deltaToken.js';
|
|
3
|
+
/**
|
|
4
|
+
* Core list-operations engine. Pure-ish TypeScript: takes a `UserInfo` +
|
|
5
|
+
* optional `IMetadataProvider` and talks to data exclusively through
|
|
6
|
+
* `Metadata` / `RunView` / `BaseEntity`. No GraphQL, no HTTP, no Angular.
|
|
7
|
+
*
|
|
8
|
+
* Public methods are PascalCase per MJ convention. Internal helpers are
|
|
9
|
+
* camelCase. Every mutating method delegates through `ComputeDelta` →
|
|
10
|
+
* `ApplyDelta` to enforce the drop-row warning contract.
|
|
11
|
+
*/
|
|
12
|
+
export class ListOperations {
|
|
13
|
+
constructor(contextUser, provider) {
|
|
14
|
+
this.contextUser = contextUser;
|
|
15
|
+
this.provider = provider;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolve any `ListSource` to a concrete set of record IDs + the entity
|
|
19
|
+
* name those IDs belong to. Never mutates. Used by every higher-level
|
|
20
|
+
* operation (`ComputeDelta`, `ComputeSetOp`, audience resolution, etc.).
|
|
21
|
+
*
|
|
22
|
+
* Record IDs are returned in MJ List Detail format — single-PK entities
|
|
23
|
+
* use the raw value; composite-PK entities use the canonical
|
|
24
|
+
* `Field1|Value1||Field2|Value2` form (`CompositeKey.ToConcatenatedString`).
|
|
25
|
+
*/
|
|
26
|
+
async ResolveSource(source) {
|
|
27
|
+
switch (source.kind) {
|
|
28
|
+
case 'list':
|
|
29
|
+
return this.resolveListSource(source.listId);
|
|
30
|
+
case 'view':
|
|
31
|
+
return this.resolveViewSource(source.viewId, source.runtimeParams);
|
|
32
|
+
case 'adhoc':
|
|
33
|
+
return this.resolveAdhocSource(source.entityName, source.extraFilter);
|
|
34
|
+
default: {
|
|
35
|
+
// Exhaustiveness check — if a new kind is added to ListSource the
|
|
36
|
+
// compiler will reject this branch.
|
|
37
|
+
const exhaustive = source;
|
|
38
|
+
throw new Error(`Unknown ListSource kind: ${JSON.stringify(exhaustive)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Return the current members of a list as record IDs. Convenience wrapper
|
|
44
|
+
* over `ResolveSource({ kind: 'list', listId })`.
|
|
45
|
+
*/
|
|
46
|
+
async GetListMembers(listId) {
|
|
47
|
+
return this.resolveListSource(listId);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Preview a refresh / materialization. Never mutates. Returns a fully
|
|
51
|
+
* populated `ListDelta` including a signed `DeltaToken` that `ApplyDelta`
|
|
52
|
+
* will accept (subject to TTL + re-computation + permission checks).
|
|
53
|
+
*
|
|
54
|
+
* - `target = 'new'`: equivalent to materializing a new list — every
|
|
55
|
+
* record in `source` becomes a `ToAdd`, no removals are possible.
|
|
56
|
+
* - `target = ListSource(kind:'list')`: a refresh of that list against
|
|
57
|
+
* `source`. `mode` controls whether removals are allowed (`Sync`) or
|
|
58
|
+
* forbidden (`Additive`).
|
|
59
|
+
*/
|
|
60
|
+
async ComputeDelta(target, source, mode) {
|
|
61
|
+
const sourceSet = await this.ResolveSource(source);
|
|
62
|
+
if (target === 'new') {
|
|
63
|
+
return await this.buildDelta({
|
|
64
|
+
targetListId: null,
|
|
65
|
+
entityName: sourceSet.EntityName,
|
|
66
|
+
toAddIds: sourceSet.RecordIds,
|
|
67
|
+
toRemoveIds: [],
|
|
68
|
+
unchangedIds: [],
|
|
69
|
+
warnings: this.buildWarnings(sourceSet, null, 0),
|
|
70
|
+
tokenMode: mode,
|
|
71
|
+
signatureRecordIds: sourceSet.RecordIds,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const targetSet = await this.ResolveSource(target);
|
|
75
|
+
this.assertEntitiesMatch(sourceSet, targetSet);
|
|
76
|
+
const sourceIds = new Set(sourceSet.RecordIds);
|
|
77
|
+
const targetIds = new Set(targetSet.RecordIds);
|
|
78
|
+
const toAddIds = [];
|
|
79
|
+
for (const id of sourceIds)
|
|
80
|
+
if (!targetIds.has(id))
|
|
81
|
+
toAddIds.push(id);
|
|
82
|
+
// Additive mode never drops — even if records exist in target but not in
|
|
83
|
+
// source, leave them alone. Sync mode reconciles in both directions.
|
|
84
|
+
const toRemoveIds = mode === 'Sync' ? [...targetIds].filter((id) => !sourceIds.has(id)) : [];
|
|
85
|
+
const unchangedIds = [...targetIds].filter((id) => sourceIds.has(id));
|
|
86
|
+
return await this.buildDelta({
|
|
87
|
+
targetListId: target.kind === 'list' ? target.listId : null,
|
|
88
|
+
entityName: sourceSet.EntityName,
|
|
89
|
+
toAddIds,
|
|
90
|
+
toRemoveIds,
|
|
91
|
+
unchangedIds,
|
|
92
|
+
warnings: this.buildWarnings(sourceSet, targetSet, toRemoveIds.length),
|
|
93
|
+
tokenMode: mode,
|
|
94
|
+
signatureRecordIds: sourceSet.RecordIds,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Preview a set-op (union / intersection / difference) across two or more
|
|
99
|
+
* sources, optionally projected into a target list. Same drop-warning
|
|
100
|
+
* semantics as `ComputeDelta`: any operation that would remove records
|
|
101
|
+
* from an existing target sets `Counts.Remove > 0` and produces a
|
|
102
|
+
* `WILL_REMOVE_RECORDS` warning.
|
|
103
|
+
*
|
|
104
|
+
* `difference` is left-to-right: `inputs[0] − inputs[1] − inputs[2] …`.
|
|
105
|
+
*/
|
|
106
|
+
async ComputeSetOp(op, inputs, target) {
|
|
107
|
+
if (inputs.length < 2) {
|
|
108
|
+
throw new Error(`ComputeSetOp requires at least 2 inputs, got ${inputs.length}`);
|
|
109
|
+
}
|
|
110
|
+
const resolved = await Promise.all(inputs.map((src) => this.ResolveSource(src)));
|
|
111
|
+
const entityWarnings = this.detectMixedEntities(resolved);
|
|
112
|
+
const entityName = resolved[0].EntityName;
|
|
113
|
+
const resultIds = this.applySetOp(op, resolved);
|
|
114
|
+
if (!target || target === 'new') {
|
|
115
|
+
return await this.buildDelta({
|
|
116
|
+
targetListId: null,
|
|
117
|
+
entityName,
|
|
118
|
+
toAddIds: resultIds,
|
|
119
|
+
toRemoveIds: [],
|
|
120
|
+
unchangedIds: [],
|
|
121
|
+
warnings: entityWarnings,
|
|
122
|
+
tokenMode: 'SetOp',
|
|
123
|
+
signatureRecordIds: resultIds,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const targetSet = await this.ResolveSource(target);
|
|
127
|
+
if (targetSet.EntityName !== entityName) {
|
|
128
|
+
entityWarnings.push({
|
|
129
|
+
Code: 'ENTITY_MISMATCH',
|
|
130
|
+
Message: `Target list entity '${targetSet.EntityName}' differs from set-op entity '${entityName}'`,
|
|
131
|
+
Details: { TargetEntity: targetSet.EntityName, SourceEntity: entityName },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const resultSet = new Set(resultIds);
|
|
135
|
+
const targetIds = new Set(targetSet.RecordIds);
|
|
136
|
+
const toAddIds = resultIds.filter((id) => !targetIds.has(id));
|
|
137
|
+
const toRemoveIds = [...targetIds].filter((id) => !resultSet.has(id));
|
|
138
|
+
const unchangedIds = [...targetIds].filter((id) => resultSet.has(id));
|
|
139
|
+
return await this.buildDelta({
|
|
140
|
+
targetListId: target.kind === 'list' ? target.listId : null,
|
|
141
|
+
entityName,
|
|
142
|
+
toAddIds,
|
|
143
|
+
toRemoveIds,
|
|
144
|
+
unchangedIds,
|
|
145
|
+
warnings: [
|
|
146
|
+
...entityWarnings,
|
|
147
|
+
...this.buildWarnings({ EntityName: entityName, RecordIds: resultIds }, targetSet, toRemoveIds.length),
|
|
148
|
+
],
|
|
149
|
+
tokenMode: 'SetOp',
|
|
150
|
+
signatureRecordIds: resultIds,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Apply a previously previewed `ListDelta` to its target list. Enforces
|
|
155
|
+
* the full drop-row warning contract (server-side, non-bypassable):
|
|
156
|
+
*
|
|
157
|
+
* 1. The token must verify (signature + 5-min TTL).
|
|
158
|
+
* 2. If the delta would remove records, `opts.ConfirmDrops` must be true.
|
|
159
|
+
* 3. The target list's current membership must still equal what the
|
|
160
|
+
* preview observed — otherwise `STALE_DELTA` (UI is expected to
|
|
161
|
+
* re-preview and ask the user again).
|
|
162
|
+
* 4. Caller must hold Editor permission (placeholder — Phase 2 wires
|
|
163
|
+
* the real ResourcePermission check).
|
|
164
|
+
*
|
|
165
|
+
* Only mutates when all four pass. Concurrent mutations between preview
|
|
166
|
+
* and apply surface as `STALE_DELTA` rather than silently overwriting.
|
|
167
|
+
*/
|
|
168
|
+
async ApplyDelta(delta, opts) {
|
|
169
|
+
const tokenResult = await this.verifyTokenForDelta(delta, opts.DeltaToken);
|
|
170
|
+
if (!tokenResult.ok)
|
|
171
|
+
return tokenResult.failure;
|
|
172
|
+
if (delta.Counts.Remove > 0 && !opts.ConfirmDrops) {
|
|
173
|
+
return this.failure('DROP_NOT_CONFIRMED', `Apply would remove ${delta.Counts.Remove} record(s). Pass ConfirmDrops: true to proceed.`);
|
|
174
|
+
}
|
|
175
|
+
if (!delta.TargetListId) {
|
|
176
|
+
// New-list creation flows through MaterializeFromView (Phase 1), not
|
|
177
|
+
// ApplyDelta directly — keeping ApplyDelta's contract single-purpose.
|
|
178
|
+
return this.failure('TARGET_NOT_FOUND', 'ApplyDelta requires an existing TargetListId. Use MaterializeFromView for new lists.');
|
|
179
|
+
}
|
|
180
|
+
const permission = await this.checkEditorPermission(delta.TargetListId);
|
|
181
|
+
if (!permission.ok)
|
|
182
|
+
return permission.failure;
|
|
183
|
+
const staleness = await this.verifyTargetNotDrifted(delta);
|
|
184
|
+
if (!staleness.ok)
|
|
185
|
+
return staleness.failure;
|
|
186
|
+
return this.applyDeltaMutations(delta);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Materialize a new list from a User View. Creates an `MJ: List` record,
|
|
190
|
+
* captures lineage when requested, and bulk-inserts the resolved members
|
|
191
|
+
* as `MJ: List Details` rows.
|
|
192
|
+
*
|
|
193
|
+
* Lineage semantics:
|
|
194
|
+
* - `RememberLineage = false` → one-shot copy; the list cannot be
|
|
195
|
+
* refreshed against the view later. Useful when the user explicitly
|
|
196
|
+
* wants a frozen point-in-time snapshot decoupled from the view.
|
|
197
|
+
* - `RememberLineage = true, UseSnapshot = false` → list remembers
|
|
198
|
+
* `SourceViewID`; future refreshes re-run the **live** view.
|
|
199
|
+
* - `RememberLineage = true, UseSnapshot = true` → list remembers
|
|
200
|
+
* `SourceViewID` and a JSON snapshot of the view's filter state;
|
|
201
|
+
* future refreshes re-evaluate that **snapshot** even if the view
|
|
202
|
+
* itself has since been edited.
|
|
203
|
+
*
|
|
204
|
+
* Never produces drops (target is brand new) — no delta-token needed.
|
|
205
|
+
*/
|
|
206
|
+
async MaterializeFromView(viewId, opts) {
|
|
207
|
+
// 1. Resolve the view's current members. This both validates the view
|
|
208
|
+
// exists and gives us the EntityID we need for the new list.
|
|
209
|
+
const resolved = await this.ResolveSource({ kind: 'view', viewId });
|
|
210
|
+
const entityInfo = this.metadata().EntityByName(resolved.EntityName);
|
|
211
|
+
if (!entityInfo) {
|
|
212
|
+
return this.failure('UNEXPECTED_ERROR', `Entity '${resolved.EntityName}' not found in metadata`);
|
|
213
|
+
}
|
|
214
|
+
// 2. Optionally snapshot the view's filter state for snapshot-mode refresh.
|
|
215
|
+
const filterSnapshot = opts.RememberLineage && opts.UseSnapshot
|
|
216
|
+
? await this.captureViewFilterSnapshot(viewId)
|
|
217
|
+
: null;
|
|
218
|
+
// 3. Create + save the list record.
|
|
219
|
+
const listResult = await this.createListWithLineage({
|
|
220
|
+
entityId: entityInfo.ID,
|
|
221
|
+
viewId: opts.RememberLineage ? viewId : null,
|
|
222
|
+
filterSnapshot,
|
|
223
|
+
opts,
|
|
224
|
+
});
|
|
225
|
+
if (!listResult.ok)
|
|
226
|
+
return listResult.failure;
|
|
227
|
+
const listId = listResult.listId;
|
|
228
|
+
// 4. Bulk-insert the resolved members. Any per-record failure is
|
|
229
|
+
// surfaced in the result rather than aborting the whole batch.
|
|
230
|
+
const insertResult = await this.insertListMembers(listId, resolved.RecordIds);
|
|
231
|
+
return {
|
|
232
|
+
Success: insertResult.failed === 0,
|
|
233
|
+
ResultCode: insertResult.failed === 0 ? 'SUCCESS' : 'PARTIAL_SUCCESS',
|
|
234
|
+
Message: insertResult.failed === 0
|
|
235
|
+
? `Materialized list with ${insertResult.added} member(s)`
|
|
236
|
+
: `Materialized list with ${insertResult.added} added, ${insertResult.failed} failed`,
|
|
237
|
+
CreatedListId: listId,
|
|
238
|
+
TargetListId: listId,
|
|
239
|
+
Counts: { Added: insertResult.added, Removed: 0, Failed: insertResult.failed },
|
|
240
|
+
Errors: insertResult.errors.length > 0 ? insertResult.errors : undefined,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Refresh an existing list against its captured source view. The list
|
|
245
|
+
* must have been created with `RememberLineage = true` (i.e. it has a
|
|
246
|
+
* non-null `SourceViewID`) — otherwise this returns `TARGET_NOT_FOUND`.
|
|
247
|
+
*
|
|
248
|
+
* When `list.UseSnapshot = true`, the source is re-evaluated against the
|
|
249
|
+
* filter snapshot captured at materialization time. When `false`, the
|
|
250
|
+
* live view is re-run — which means edits to the view since
|
|
251
|
+
* materialization will be reflected in the result.
|
|
252
|
+
*
|
|
253
|
+
* In `Sync` mode, callers MUST pass `ConfirmDrops: true` or the
|
|
254
|
+
* server-side drop guard will reject the apply with `DROP_NOT_CONFIRMED`.
|
|
255
|
+
* On success, `LastRefreshedAt` and `LastRefreshedByUserID` are updated.
|
|
256
|
+
*/
|
|
257
|
+
async RefreshFromSource(listId, mode, opts) {
|
|
258
|
+
const sourceResolution = await this.resolveRefreshSource(listId);
|
|
259
|
+
if (!sourceResolution.ok)
|
|
260
|
+
return sourceResolution.failure;
|
|
261
|
+
const delta = await this.ComputeDelta({ kind: 'list', listId }, sourceResolution.source, mode);
|
|
262
|
+
const result = await this.ApplyDelta(delta, {
|
|
263
|
+
ConfirmDrops: opts.ConfirmDrops,
|
|
264
|
+
DeltaToken: delta.DeltaToken,
|
|
265
|
+
});
|
|
266
|
+
if (result.Success) {
|
|
267
|
+
await this.stampRefreshMetadata(listId);
|
|
268
|
+
}
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Add a view's results to an existing list. Always additive — never
|
|
273
|
+
* removes existing members. Dedupes silently, so safe to re-run.
|
|
274
|
+
*
|
|
275
|
+
* No drop-confirmation needed (this op cannot drop). The implementation
|
|
276
|
+
* pipes through `ComputeDelta` + `ApplyDelta` so it still gets the
|
|
277
|
+
* token/staleness/permission machinery for free.
|
|
278
|
+
*/
|
|
279
|
+
async AddViewResultsToList(viewId, listId) {
|
|
280
|
+
const delta = await this.ComputeDelta({ kind: 'list', listId }, { kind: 'view', viewId }, 'Additive');
|
|
281
|
+
return this.ApplyDelta(delta, { ConfirmDrops: false, DeltaToken: delta.DeltaToken });
|
|
282
|
+
}
|
|
283
|
+
// --- private helpers --------------------------------------------------
|
|
284
|
+
async resolveListSource(listId) {
|
|
285
|
+
const md = this.metadata();
|
|
286
|
+
const list = await md.GetEntityObject('MJ: Lists', this.contextUser);
|
|
287
|
+
const loaded = await list.Load(listId);
|
|
288
|
+
if (!loaded) {
|
|
289
|
+
throw new Error(`List '${listId}' not found`);
|
|
290
|
+
}
|
|
291
|
+
const entityName = this.resolveEntityNameFromList(list);
|
|
292
|
+
const rv = this.runView();
|
|
293
|
+
const result = await rv.RunView({
|
|
294
|
+
EntityName: 'MJ: List Details',
|
|
295
|
+
ExtraFilter: `ListID='${listId}'`,
|
|
296
|
+
Fields: ['RecordID'],
|
|
297
|
+
ResultType: 'simple',
|
|
298
|
+
}, this.contextUser);
|
|
299
|
+
if (!result.Success) {
|
|
300
|
+
throw new Error(`Failed to load list members for '${listId}': ${result.ErrorMessage}`);
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
EntityName: entityName,
|
|
304
|
+
RecordIds: (result.Results ?? []).map((row) => String(row.RecordID)),
|
|
305
|
+
TotalCount: result.RowCount,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
async resolveViewSource(viewId, runtimeParams) {
|
|
309
|
+
const rv = this.runView();
|
|
310
|
+
const entityName = await RunView.GetEntityNameFromRunViewParams({ ViewID: viewId }, this.provider ?? null);
|
|
311
|
+
if (!entityName) {
|
|
312
|
+
throw new Error(`Could not determine entity for view '${viewId}'`);
|
|
313
|
+
}
|
|
314
|
+
const entityInfo = this.metadata().EntityByName(entityName);
|
|
315
|
+
if (!entityInfo) {
|
|
316
|
+
throw new Error(`Entity '${entityName}' not found in metadata`);
|
|
317
|
+
}
|
|
318
|
+
const pkFields = entityInfo.PrimaryKeys.map((pk) => pk.Name);
|
|
319
|
+
// runtimeParams is reserved on ListSource for a future parameterized-view
|
|
320
|
+
// capability; RunView has no first-class runtime-parameter slot today, so
|
|
321
|
+
// we ignore them at this layer rather than silently routing them somewhere
|
|
322
|
+
// misleading. If/when MJ adds parameterized views, wire them in here.
|
|
323
|
+
void runtimeParams;
|
|
324
|
+
const result = await rv.RunView({
|
|
325
|
+
ViewID: viewId,
|
|
326
|
+
Fields: pkFields,
|
|
327
|
+
ResultType: 'simple',
|
|
328
|
+
}, this.contextUser);
|
|
329
|
+
if (!result.Success) {
|
|
330
|
+
throw new Error(`Failed to run view '${viewId}': ${result.ErrorMessage}`);
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
EntityName: entityName,
|
|
334
|
+
RecordIds: (result.Results ?? []).map((row) => this.serializeRecordId(entityInfo, row)),
|
|
335
|
+
TotalCount: result.RowCount,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
async resolveAdhocSource(entityName, extraFilter) {
|
|
339
|
+
const entityInfo = this.metadata().EntityByName(entityName);
|
|
340
|
+
if (!entityInfo) {
|
|
341
|
+
throw new Error(`Entity '${entityName}' not found in metadata`);
|
|
342
|
+
}
|
|
343
|
+
const pkFields = entityInfo.PrimaryKeys.map((pk) => pk.Name);
|
|
344
|
+
const rv = this.runView();
|
|
345
|
+
const result = await rv.RunView({
|
|
346
|
+
EntityName: entityName,
|
|
347
|
+
ExtraFilter: extraFilter,
|
|
348
|
+
Fields: pkFields,
|
|
349
|
+
ResultType: 'simple',
|
|
350
|
+
}, this.contextUser);
|
|
351
|
+
if (!result.Success) {
|
|
352
|
+
throw new Error(`Failed to run ad-hoc filter on '${entityName}': ${result.ErrorMessage}`);
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
EntityName: entityName,
|
|
356
|
+
RecordIds: (result.Results ?? []).map((row) => this.serializeRecordId(entityInfo, row)),
|
|
357
|
+
TotalCount: result.RowCount,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Resolve a list's entity name from its `EntityID` foreign key.
|
|
362
|
+
* Single-entity is intentional — multi-entity lists are not supported.
|
|
363
|
+
*/
|
|
364
|
+
resolveEntityNameFromList(list) {
|
|
365
|
+
const md = this.metadata();
|
|
366
|
+
// EntityByID is O(1) over the pre-populated entity map; Entities.find
|
|
367
|
+
// is an O(N) array scan and the documented anti-pattern.
|
|
368
|
+
const entity = md.EntityByID(list.EntityID);
|
|
369
|
+
if (!entity) {
|
|
370
|
+
throw new Error(`List '${list.ID}' references unknown EntityID '${list.EntityID}'`);
|
|
371
|
+
}
|
|
372
|
+
return entity.Name;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Serialize a primary-key tuple into the MJ List Detail `RecordID` format.
|
|
376
|
+
* Single-PK entities return the raw value; composite-PK entities use the
|
|
377
|
+
* canonical `Field1|Value1||Field2|Value2` concatenation matching
|
|
378
|
+
* `CompositeKey.ToConcatenatedString` defaults.
|
|
379
|
+
*/
|
|
380
|
+
serializeRecordId(entityInfo, row) {
|
|
381
|
+
if (entityInfo.PrimaryKeys.length === 1) {
|
|
382
|
+
const pkName = entityInfo.PrimaryKeys[0].Name;
|
|
383
|
+
return String(row[pkName]);
|
|
384
|
+
}
|
|
385
|
+
const ck = new CompositeKey();
|
|
386
|
+
ck.KeyValuePairs = entityInfo.PrimaryKeys.map((pk) => ({
|
|
387
|
+
FieldName: pk.Name,
|
|
388
|
+
Value: row[pk.Name],
|
|
389
|
+
}));
|
|
390
|
+
return ck.ToConcatenatedString();
|
|
391
|
+
}
|
|
392
|
+
async buildDelta(args) {
|
|
393
|
+
const counts = {
|
|
394
|
+
Add: args.toAddIds.length,
|
|
395
|
+
Remove: args.toRemoveIds.length,
|
|
396
|
+
Unchanged: args.unchangedIds.length,
|
|
397
|
+
SourceTotal: args.toAddIds.length + args.unchangedIds.length,
|
|
398
|
+
TargetTotal: args.unchangedIds.length + args.toRemoveIds.length,
|
|
399
|
+
};
|
|
400
|
+
// ComputeSourceSignature and SignDeltaToken are async because Web
|
|
401
|
+
// Crypto's SubtleCrypto is async. Build them in sequence — the
|
|
402
|
+
// signature feeds the token payload.
|
|
403
|
+
const ssig = await ComputeSourceSignature(args.signatureRecordIds);
|
|
404
|
+
const payload = {
|
|
405
|
+
v: 1,
|
|
406
|
+
tid: args.targetListId,
|
|
407
|
+
ssig,
|
|
408
|
+
m: args.tokenMode,
|
|
409
|
+
iat: Date.now(),
|
|
410
|
+
};
|
|
411
|
+
return {
|
|
412
|
+
TargetListId: args.targetListId,
|
|
413
|
+
EntityName: args.entityName,
|
|
414
|
+
ToAdd: args.toAddIds,
|
|
415
|
+
ToRemove: args.toRemoveIds,
|
|
416
|
+
Unchanged: args.unchangedIds,
|
|
417
|
+
Counts: counts,
|
|
418
|
+
Warnings: args.warnings,
|
|
419
|
+
DeltaToken: await SignDeltaToken(payload),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
buildWarnings(sourceSet, targetSet, removeCount) {
|
|
423
|
+
const out = [];
|
|
424
|
+
if (removeCount > 0) {
|
|
425
|
+
out.push({
|
|
426
|
+
Code: 'WILL_REMOVE_RECORDS',
|
|
427
|
+
Message: `${removeCount} record(s) will be removed from the target list`,
|
|
428
|
+
Details: { Count: removeCount },
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
if (sourceSet.RecordIds.length === 0) {
|
|
432
|
+
out.push({
|
|
433
|
+
Code: 'EMPTY_SOURCE',
|
|
434
|
+
Message: 'Source resolved to zero records',
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
if (targetSet && targetSet.RecordIds.length === 0) {
|
|
438
|
+
out.push({
|
|
439
|
+
Code: 'EMPTY_TARGET',
|
|
440
|
+
Message: 'Target list has no current members',
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
if (targetSet && sourceSet.EntityName !== targetSet.EntityName) {
|
|
444
|
+
out.push({
|
|
445
|
+
Code: 'ENTITY_MISMATCH',
|
|
446
|
+
Message: `Source entity '${sourceSet.EntityName}' does not match target entity '${targetSet.EntityName}'`,
|
|
447
|
+
Details: { SourceEntity: sourceSet.EntityName, TargetEntity: targetSet.EntityName },
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
return out;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Apply a set-op across N already-resolved sources. We accept the resolved
|
|
454
|
+
* sets (not raw `ListSource[]`) so callers can decide how to handle mixed
|
|
455
|
+
* entities before we collapse identities to strings.
|
|
456
|
+
*/
|
|
457
|
+
applySetOp(op, resolved) {
|
|
458
|
+
const sets = resolved.map((r) => new Set(r.RecordIds));
|
|
459
|
+
switch (op) {
|
|
460
|
+
case 'union': {
|
|
461
|
+
const out = new Set();
|
|
462
|
+
for (const s of sets)
|
|
463
|
+
for (const id of s)
|
|
464
|
+
out.add(id);
|
|
465
|
+
return [...out];
|
|
466
|
+
}
|
|
467
|
+
case 'intersection': {
|
|
468
|
+
if (sets.length === 0)
|
|
469
|
+
return [];
|
|
470
|
+
const [first, ...rest] = sets;
|
|
471
|
+
return [...first].filter((id) => rest.every((s) => s.has(id)));
|
|
472
|
+
}
|
|
473
|
+
case 'difference': {
|
|
474
|
+
const [first, ...rest] = sets;
|
|
475
|
+
const subtract = new Set();
|
|
476
|
+
for (const s of rest)
|
|
477
|
+
for (const id of s)
|
|
478
|
+
subtract.add(id);
|
|
479
|
+
return [...first].filter((id) => !subtract.has(id));
|
|
480
|
+
}
|
|
481
|
+
default: {
|
|
482
|
+
const exhaustive = op;
|
|
483
|
+
throw new Error(`Unknown set-op kind: ${JSON.stringify(exhaustive)}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
detectMixedEntities(resolved) {
|
|
488
|
+
const entities = new Set(resolved.map((r) => r.EntityName));
|
|
489
|
+
if (entities.size <= 1)
|
|
490
|
+
return [];
|
|
491
|
+
return [
|
|
492
|
+
{
|
|
493
|
+
Code: 'ENTITY_MISMATCH',
|
|
494
|
+
Message: `Set-op inputs span multiple entities: ${[...entities].join(', ')}`,
|
|
495
|
+
Details: { Entities: [...entities] },
|
|
496
|
+
},
|
|
497
|
+
];
|
|
498
|
+
}
|
|
499
|
+
assertEntitiesMatch(a, b) {
|
|
500
|
+
// Surface a warning rather than throw — the caller (UI) decides whether
|
|
501
|
+
// to proceed. The actual mismatch is captured in `buildWarnings` so it
|
|
502
|
+
// appears in `delta.Warnings` for the user.
|
|
503
|
+
void a;
|
|
504
|
+
void b;
|
|
505
|
+
}
|
|
506
|
+
async verifyTokenForDelta(delta, token) {
|
|
507
|
+
let payload;
|
|
508
|
+
try {
|
|
509
|
+
payload = await VerifyDeltaToken(token);
|
|
510
|
+
}
|
|
511
|
+
catch (e) {
|
|
512
|
+
const isExpired = e instanceof DeltaTokenVerificationError && e.Code === 'EXPIRED_TOKEN';
|
|
513
|
+
return {
|
|
514
|
+
ok: false,
|
|
515
|
+
failure: this.failure(isExpired ? 'STALE_DELTA' : 'INVALID_TOKEN', e instanceof Error ? e.message : String(e)),
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
// Token must describe the same target the delta does — prevents replay
|
|
519
|
+
// of a token issued for list A against list B.
|
|
520
|
+
if (payload.tid !== delta.TargetListId) {
|
|
521
|
+
return {
|
|
522
|
+
ok: false,
|
|
523
|
+
failure: this.failure('INVALID_TOKEN', 'Delta token target does not match delta target'),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
// The signature is over the *source* record IDs at preview time. If the
|
|
527
|
+
// source has been mutated since the preview, the token's `ssig` will no
|
|
528
|
+
// longer match what `ComputeDelta` would produce now. Higher-level
|
|
529
|
+
// operations (which know the source) re-resolve and re-check; the
|
|
530
|
+
// generic `ApplyDelta` path trusts the token here and relies on
|
|
531
|
+
// target-drift detection below to catch concurrent mutations.
|
|
532
|
+
return { ok: true, payload };
|
|
533
|
+
}
|
|
534
|
+
async verifyTargetNotDrifted(delta) {
|
|
535
|
+
if (!delta.TargetListId)
|
|
536
|
+
return { ok: true };
|
|
537
|
+
// Re-resolve via the public method so consumers can intercept the
|
|
538
|
+
// resolution (tests do this; future caching layers might too).
|
|
539
|
+
const current = await this.ResolveSource({ kind: 'list', listId: delta.TargetListId });
|
|
540
|
+
const expected = new Set([...delta.Unchanged, ...delta.ToRemove]);
|
|
541
|
+
const actual = new Set(current.RecordIds);
|
|
542
|
+
if (expected.size !== actual.size) {
|
|
543
|
+
return { ok: false, failure: this.failure('STALE_DELTA', 'Target list has been modified since preview was generated.') };
|
|
544
|
+
}
|
|
545
|
+
for (const id of expected) {
|
|
546
|
+
if (!actual.has(id)) {
|
|
547
|
+
return {
|
|
548
|
+
ok: false,
|
|
549
|
+
failure: this.failure('STALE_DELTA', 'Target list has been modified since preview was generated.'),
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return { ok: true };
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Placeholder for the Phase 2 ResourcePermission check. Phase 0 ships a
|
|
557
|
+
* stub so the call site exists and the failure mode is reachable in
|
|
558
|
+
* tests, but it currently always passes — server bootstrap MUST wire in
|
|
559
|
+
* the real check before Phase 2 ships.
|
|
560
|
+
*/
|
|
561
|
+
async checkEditorPermission(targetListId) {
|
|
562
|
+
void targetListId;
|
|
563
|
+
return { ok: true };
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Idempotent batch apply. We persist add/remove independently so a
|
|
567
|
+
* single bad record (e.g. constraint violation) doesn't abort the whole
|
|
568
|
+
* delta — we collect failures and surface a `PARTIAL_SUCCESS` result.
|
|
569
|
+
*/
|
|
570
|
+
async applyDeltaMutations(delta) {
|
|
571
|
+
const md = this.metadata();
|
|
572
|
+
let added = 0;
|
|
573
|
+
let removed = 0;
|
|
574
|
+
let failed = 0;
|
|
575
|
+
const errors = [];
|
|
576
|
+
for (const recordId of delta.ToAdd) {
|
|
577
|
+
const detail = await md.GetEntityObject('MJ: List Details', this.contextUser);
|
|
578
|
+
detail.NewRecord();
|
|
579
|
+
detail.ListID = delta.TargetListId;
|
|
580
|
+
detail.RecordID = recordId;
|
|
581
|
+
detail.Sequence = 0;
|
|
582
|
+
const saved = await detail.Save();
|
|
583
|
+
if (saved) {
|
|
584
|
+
added++;
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
failed++;
|
|
588
|
+
const msg = detail.LatestResult?.CompleteMessage ?? 'unknown error';
|
|
589
|
+
errors.push(`Failed to add '${recordId}': ${msg}`);
|
|
590
|
+
LogError(`ApplyDelta add failed for list ${delta.TargetListId} record ${recordId}: ${msg}`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (delta.ToRemove.length > 0) {
|
|
594
|
+
const removalErrors = await this.removeDeltaRecords(delta.TargetListId, delta.ToRemove);
|
|
595
|
+
removed = delta.ToRemove.length - removalErrors.length;
|
|
596
|
+
failed += removalErrors.length;
|
|
597
|
+
errors.push(...removalErrors);
|
|
598
|
+
}
|
|
599
|
+
const success = failed === 0;
|
|
600
|
+
const code = success ? 'SUCCESS' : 'PARTIAL_SUCCESS';
|
|
601
|
+
const result = {
|
|
602
|
+
Success: success,
|
|
603
|
+
ResultCode: code,
|
|
604
|
+
Message: success
|
|
605
|
+
? `Applied delta: +${added} / -${removed}`
|
|
606
|
+
: `Applied with errors: +${added} / -${removed}, ${failed} failed`,
|
|
607
|
+
TargetListId: delta.TargetListId,
|
|
608
|
+
Counts: { Added: added, Removed: removed, Failed: failed },
|
|
609
|
+
Errors: errors.length > 0 ? errors : undefined,
|
|
610
|
+
};
|
|
611
|
+
LogStatus(`ListOperations.ApplyDelta target=${delta.TargetListId} +${added}/-${removed}/!${failed}`);
|
|
612
|
+
return result;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Locate and delete the `MJ: List Details` rows that map to the given
|
|
616
|
+
* record IDs. We scope the lookup to a single `ListID` so a bad RecordID
|
|
617
|
+
* value can't accidentally drop rows from other lists.
|
|
618
|
+
*/
|
|
619
|
+
async removeDeltaRecords(targetListId, recordIds) {
|
|
620
|
+
if (recordIds.length === 0)
|
|
621
|
+
return [];
|
|
622
|
+
const rv = this.runView();
|
|
623
|
+
const filterIds = recordIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',');
|
|
624
|
+
const result = await rv.RunView({
|
|
625
|
+
EntityName: 'MJ: List Details',
|
|
626
|
+
ExtraFilter: `ListID='${targetListId}' AND RecordID IN (${filterIds})`,
|
|
627
|
+
ResultType: 'entity_object',
|
|
628
|
+
}, this.contextUser);
|
|
629
|
+
const errors = [];
|
|
630
|
+
if (!result.Success) {
|
|
631
|
+
return recordIds.map((id) => `Lookup failed for '${id}': ${result.ErrorMessage}`);
|
|
632
|
+
}
|
|
633
|
+
for (const detail of result.Results ?? []) {
|
|
634
|
+
const ok = await detail.Delete();
|
|
635
|
+
if (!ok) {
|
|
636
|
+
const msg = detail.LatestResult?.CompleteMessage ?? 'unknown error';
|
|
637
|
+
errors.push(`Failed to remove '${detail.RecordID}': ${msg}`);
|
|
638
|
+
LogError(`ApplyDelta remove failed for list ${targetListId} record ${detail.RecordID}: ${msg}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return errors;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Pick the source for a refresh based on the list's lineage. Live mode
|
|
645
|
+
* just points back at the original view ID. Snapshot mode reads the
|
|
646
|
+
* captured filter blob and constructs an ad-hoc source against the
|
|
647
|
+
* list's entity using whichever WHERE clause the view had at
|
|
648
|
+
* materialization time.
|
|
649
|
+
*/
|
|
650
|
+
async resolveRefreshSource(listId) {
|
|
651
|
+
const md = this.metadata();
|
|
652
|
+
const list = await md.GetEntityObject('MJ: Lists', this.contextUser);
|
|
653
|
+
const loaded = await list.Load(listId);
|
|
654
|
+
if (!loaded) {
|
|
655
|
+
return { ok: false, failure: this.failure('TARGET_NOT_FOUND', `List '${listId}' not found`) };
|
|
656
|
+
}
|
|
657
|
+
if (!list.SourceViewID) {
|
|
658
|
+
return {
|
|
659
|
+
ok: false,
|
|
660
|
+
failure: this.failure('TARGET_NOT_FOUND', `List '${listId}' has no SourceViewID — refresh is only supported for lists materialized with lineage.`),
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
if (!list.UseSnapshot) {
|
|
664
|
+
return { ok: true, source: { kind: 'view', viewId: list.SourceViewID } };
|
|
665
|
+
}
|
|
666
|
+
// Snapshot mode — re-evaluate the captured filter against the list's entity.
|
|
667
|
+
const adhoc = this.buildAdhocSourceFromSnapshot(list);
|
|
668
|
+
if (!adhoc) {
|
|
669
|
+
return {
|
|
670
|
+
ok: false,
|
|
671
|
+
failure: this.failure('UNEXPECTED_ERROR', `List '${listId}' has UseSnapshot=1 but no usable SourceFilterSnapshot — re-materialize the list to capture one.`),
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
return { ok: true, source: adhoc };
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Parse the captured `SourceFilterSnapshot` and turn it into an ad-hoc
|
|
678
|
+
* `ListSource`. We prefer `customWhereClause` over `whereClause` because
|
|
679
|
+
* the custom form is the user-authored override; smart-filter clauses
|
|
680
|
+
* fall in last because they're AI-derived. Returns null if no usable
|
|
681
|
+
* clause is present (in which case the caller surfaces a clear error).
|
|
682
|
+
*/
|
|
683
|
+
buildAdhocSourceFromSnapshot(list) {
|
|
684
|
+
if (!list.SourceFilterSnapshot)
|
|
685
|
+
return null;
|
|
686
|
+
const md = this.metadata();
|
|
687
|
+
const entity = md.EntityByID(list.EntityID);
|
|
688
|
+
if (!entity)
|
|
689
|
+
return null;
|
|
690
|
+
let parsed;
|
|
691
|
+
try {
|
|
692
|
+
parsed = JSON.parse(list.SourceFilterSnapshot);
|
|
693
|
+
}
|
|
694
|
+
catch (e) {
|
|
695
|
+
LogError(`RefreshFromSource: snapshot JSON parse failed for list ${list.ID}: ${e}`);
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
const extraFilter = parsed.customWhereClause ?? parsed.whereClause ?? parsed.smartFilterWhereClause ?? null;
|
|
699
|
+
if (!extraFilter || extraFilter.trim().length === 0)
|
|
700
|
+
return null;
|
|
701
|
+
return { kind: 'adhoc', entityName: entity.Name, extraFilter };
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Bump `LastRefreshedAt` + `LastRefreshedByUserID` after a successful
|
|
705
|
+
* refresh. Best-effort — a failure here doesn't roll back the apply
|
|
706
|
+
* (the records are already in the right state), but we do log it so
|
|
707
|
+
* the discrepancy is observable.
|
|
708
|
+
*/
|
|
709
|
+
async stampRefreshMetadata(listId) {
|
|
710
|
+
const md = this.metadata();
|
|
711
|
+
const list = await md.GetEntityObject('MJ: Lists', this.contextUser);
|
|
712
|
+
const loaded = await list.Load(listId);
|
|
713
|
+
if (!loaded) {
|
|
714
|
+
LogError(`stampRefreshMetadata: list ${listId} not found after apply`);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
list.LastRefreshedAt = new Date();
|
|
718
|
+
list.LastRefreshedByUserID = this.contextUser.ID;
|
|
719
|
+
const ok = await list.Save();
|
|
720
|
+
if (!ok) {
|
|
721
|
+
LogError(`stampRefreshMetadata: save failed — ${list.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
failure(code, message) {
|
|
725
|
+
return { Success: false, ResultCode: code, Message: message };
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Load the User View and serialize its filter state into a JSON blob.
|
|
729
|
+
* We only capture the fields that actually drive the result set — grid
|
|
730
|
+
* layout, sort, and smart-filter explanation are layout/explanation
|
|
731
|
+
* metadata, not part of the filter contract, so they're omitted. The
|
|
732
|
+
* snapshot is versioned (`v: 1`) so future schema evolutions can
|
|
733
|
+
* branch on shape without breaking older snapshots.
|
|
734
|
+
*/
|
|
735
|
+
async captureViewFilterSnapshot(viewId) {
|
|
736
|
+
const md = this.metadata();
|
|
737
|
+
const view = await md.GetEntityObject('MJ: User Views', this.contextUser);
|
|
738
|
+
const loaded = await view.Load(viewId);
|
|
739
|
+
if (!loaded) {
|
|
740
|
+
LogError(`captureViewFilterSnapshot: view ${viewId} not found`);
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
const snapshot = {
|
|
744
|
+
v: 1,
|
|
745
|
+
capturedAt: new Date().toISOString(),
|
|
746
|
+
sourceViewId: viewId,
|
|
747
|
+
entityId: view.EntityID,
|
|
748
|
+
whereClause: view.WhereClause ?? null,
|
|
749
|
+
customWhereClause: view.CustomWhereClause ?? null,
|
|
750
|
+
filterState: view.FilterState ?? null,
|
|
751
|
+
customFilterState: view.CustomFilterState ?? null,
|
|
752
|
+
smartFilterEnabled: view.SmartFilterEnabled ?? false,
|
|
753
|
+
smartFilterWhereClause: view.SmartFilterWhereClause ?? null,
|
|
754
|
+
sortState: view.SortState ?? null,
|
|
755
|
+
};
|
|
756
|
+
return JSON.stringify(snapshot);
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Create + save the parent `MJ: List` record. Lineage fields are only
|
|
760
|
+
* populated when `RememberLineage = true` on `opts` — otherwise the new
|
|
761
|
+
* list is a plain one-shot copy with no upstream reference.
|
|
762
|
+
*/
|
|
763
|
+
async createListWithLineage(args) {
|
|
764
|
+
const md = this.metadata();
|
|
765
|
+
const list = await md.GetEntityObject('MJ: Lists', this.contextUser);
|
|
766
|
+
list.NewRecord();
|
|
767
|
+
list.Name = args.opts.ListName;
|
|
768
|
+
list.EntityID = args.entityId;
|
|
769
|
+
list.UserID = this.contextUser.ID;
|
|
770
|
+
if (args.opts.Description)
|
|
771
|
+
list.Description = args.opts.Description;
|
|
772
|
+
if (args.opts.CategoryId)
|
|
773
|
+
list.CategoryID = args.opts.CategoryId;
|
|
774
|
+
if (args.viewId) {
|
|
775
|
+
list.SourceViewID = args.viewId;
|
|
776
|
+
list.RefreshMode = args.opts.RefreshMode;
|
|
777
|
+
list.UseSnapshot = args.opts.UseSnapshot;
|
|
778
|
+
if (args.filterSnapshot)
|
|
779
|
+
list.SourceFilterSnapshot = args.filterSnapshot;
|
|
780
|
+
}
|
|
781
|
+
const saved = await list.Save();
|
|
782
|
+
if (!saved) {
|
|
783
|
+
const msg = list.LatestResult?.CompleteMessage ?? 'unknown error';
|
|
784
|
+
LogError(`MaterializeFromView: list save failed — ${msg}`);
|
|
785
|
+
return { ok: false, failure: this.failure('UNEXPECTED_ERROR', `Failed to create list: ${msg}`) };
|
|
786
|
+
}
|
|
787
|
+
return { ok: true, listId: list.ID };
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Bulk-insert `MJ: List Details` rows for the given record IDs. We
|
|
791
|
+
* surface a per-record error rather than aborting the whole batch so
|
|
792
|
+
* the caller can show partial-success counts.
|
|
793
|
+
*/
|
|
794
|
+
async insertListMembers(listId, recordIds) {
|
|
795
|
+
const md = this.metadata();
|
|
796
|
+
let added = 0;
|
|
797
|
+
let failed = 0;
|
|
798
|
+
const errors = [];
|
|
799
|
+
for (const recordId of recordIds) {
|
|
800
|
+
const detail = await md.GetEntityObject('MJ: List Details', this.contextUser);
|
|
801
|
+
detail.NewRecord();
|
|
802
|
+
detail.ListID = listId;
|
|
803
|
+
detail.RecordID = recordId;
|
|
804
|
+
detail.Sequence = 0;
|
|
805
|
+
const ok = await detail.Save();
|
|
806
|
+
if (ok) {
|
|
807
|
+
added++;
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
failed++;
|
|
811
|
+
const msg = detail.LatestResult?.CompleteMessage ?? 'unknown error';
|
|
812
|
+
errors.push(`Failed to add '${recordId}': ${msg}`);
|
|
813
|
+
LogError(`insertListMembers list=${listId} record=${recordId}: ${msg}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return { added, failed, errors };
|
|
817
|
+
}
|
|
818
|
+
metadata() {
|
|
819
|
+
// Per CLAUDE.md: prefer the injected provider when available; fall back
|
|
820
|
+
// to the global only when no provider is supplied. Real provider
|
|
821
|
+
// implementations (GraphQLDataProvider, SQLServerDataProvider) inherit
|
|
822
|
+
// from ProviderBase and satisfy the Metadata surface this code uses.
|
|
823
|
+
return this.provider ?? new Metadata();
|
|
824
|
+
}
|
|
825
|
+
runView() {
|
|
826
|
+
return this.provider ? RunView.FromMetadataProvider(this.provider) : new RunView();
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
//# sourceMappingURL=ListOperations.js.map
|