@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.
@@ -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