@kontourai/survey 0.4.16 → 0.4.19
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 +19 -2
- package/dist/examples/review-workbench/facility-credential-consumer.d.ts +19 -0
- package/dist/examples/review-workbench/facility-credential-consumer.js +116 -0
- package/dist/src/review-workbench/review-workbench.d.ts +45 -1
- package/dist/src/review-workbench/review-workbench.js +123 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -70,10 +70,20 @@ Survey also exposes a framework-neutral review workbench for downstream
|
|
|
70
70
|
products that already produce `ReviewItem` queues.
|
|
71
71
|
|
|
72
72
|
```ts
|
|
73
|
-
import {
|
|
73
|
+
import {
|
|
74
|
+
mountReviewWorkbench,
|
|
75
|
+
type ReviewPresentationAdapter,
|
|
76
|
+
} from "@kontourai/survey/review-workbench";
|
|
74
77
|
import "@kontourai/survey/review-workbench.css";
|
|
75
78
|
|
|
76
|
-
|
|
79
|
+
const presentationAdapter = {
|
|
80
|
+
labelForTarget: (target) => target === "registrationStatus"
|
|
81
|
+
? "Registration status"
|
|
82
|
+
: undefined,
|
|
83
|
+
linkForReviewItem: (item) => ({ href: `/review/${item.metadata.name}` }),
|
|
84
|
+
} satisfies ReviewPresentationAdapter;
|
|
85
|
+
|
|
86
|
+
mountReviewWorkbench(element, reviewQueueSession, { presentationAdapter });
|
|
77
87
|
```
|
|
78
88
|
|
|
79
89
|
The default stylesheet is scoped to `.survey-workbench-embed` and bundles the
|
|
@@ -93,6 +103,13 @@ review events, exported results, and optional Surface projection, see
|
|
|
93
103
|
That guide also covers the server-side apply boundary: producers should derive
|
|
94
104
|
write results from reviewed snapshots plus persisted events, not from
|
|
95
105
|
browser-computed decisions or exported result payloads.
|
|
106
|
+
Use `persistReviewSessionEvents` when server code needs to save review events,
|
|
107
|
+
then pass the persisted event set to `deriveReviewSessionApplyResultForSnapshot`
|
|
108
|
+
before applying product policy. Survey derives selected review results and
|
|
109
|
+
structured replay/completion issues; the producer still owns current-record
|
|
110
|
+
validation and writes.
|
|
111
|
+
For a generic, test-covered example of the consumer adapter contract, see
|
|
112
|
+
[`examples/review-workbench/facility-credential-consumer.ts`](examples/review-workbench/facility-credential-consumer.ts).
|
|
96
113
|
For the current decision on why Survey is not adding a generic review adapter
|
|
97
114
|
builder yet, see
|
|
98
115
|
[`docs/consumer-adapter-abstraction-assessment.md`](docs/consumer-adapter-abstraction-assessment.md).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { facilityCredentialReviewItemFixture } from "../../src/review-workbench/review-workbench-data.js";
|
|
2
|
+
import { buildReviewItemPresentation, buildReviewResultPresentation, buildSurfaceProjectionPreview, deriveReviewSessionApplyResultForSnapshot, initialReviewQueueSessionState, type ReviewPresentationAdapter } from "../../src/review-workbench/review-workbench.js";
|
|
3
|
+
import type { ReviewSessionEvent } from "../../src/review-resource.js";
|
|
4
|
+
export declare const facilityCredentialPresentationAdapter: ReviewPresentationAdapter;
|
|
5
|
+
export declare function buildFacilityCredentialConsumerExample(): Promise<FacilityCredentialConsumerExample>;
|
|
6
|
+
export declare const facilityCredentialConsumerExample: FacilityCredentialConsumerExample;
|
|
7
|
+
export interface FacilityCredentialConsumerExample {
|
|
8
|
+
readonly reviewItem: typeof facilityCredentialReviewItemFixture;
|
|
9
|
+
readonly reviewedSnapshot: ReturnType<typeof initialReviewQueueSessionState>;
|
|
10
|
+
readonly eventsToPersist: readonly ReviewSessionEvent[];
|
|
11
|
+
readonly persistedEvents: readonly ReviewSessionEvent[];
|
|
12
|
+
readonly persistedEventCount: number;
|
|
13
|
+
readonly applyResult: Extract<ReturnType<typeof deriveReviewSessionApplyResultForSnapshot>, {
|
|
14
|
+
readonly ok: true;
|
|
15
|
+
}>;
|
|
16
|
+
readonly itemPresentation: ReturnType<typeof buildReviewItemPresentation>;
|
|
17
|
+
readonly resultPresentation: ReturnType<typeof buildReviewResultPresentation>;
|
|
18
|
+
readonly surfaceProjectionPreview: NonNullable<ReturnType<typeof buildSurfaceProjectionPreview>>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { facilityCredentialReviewItemFixture } from "../../src/review-workbench/review-workbench-data.js";
|
|
2
|
+
import { buildReviewItemPresentation, buildReviewResultPresentation, buildReviewSessionEvents, buildSurfaceProjectionPreview, deriveReviewSessionApplyResultForSnapshot, initialReviewQueueSessionState, persistReviewSessionEvents, } from "../../src/review-workbench/review-workbench.js";
|
|
3
|
+
export const facilityCredentialPresentationAdapter = {
|
|
4
|
+
labelForTarget: (target) => target === "operatingLicenseCredential"
|
|
5
|
+
? "Operating license credential"
|
|
6
|
+
: undefined,
|
|
7
|
+
labelForCandidateRole: (role) => role === "current"
|
|
8
|
+
? "Current managed credential"
|
|
9
|
+
: role === "proposed"
|
|
10
|
+
? "Registry candidate"
|
|
11
|
+
: undefined,
|
|
12
|
+
summarizeValue: (value) => {
|
|
13
|
+
if (!isCredentialValue(value)) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const serviceSummary = value.permittedServices.length === 0
|
|
17
|
+
? "no listed services"
|
|
18
|
+
: value.permittedServices.join(", ");
|
|
19
|
+
return `${value.licenseNumber} is ${value.status} through ${value.expiresAt}; services: ${serviceSummary}`;
|
|
20
|
+
},
|
|
21
|
+
linkForReviewItem: (item) => ({
|
|
22
|
+
label: typeof item.metadata.producer?.displayName === "string"
|
|
23
|
+
? item.metadata.producer.displayName
|
|
24
|
+
: "Review item",
|
|
25
|
+
href: `/review/items/${encodeURIComponent(item.metadata.name)}`,
|
|
26
|
+
}),
|
|
27
|
+
linkForSource: (sourceRef, { candidate }) => ({
|
|
28
|
+
label: candidate.role === "current" ? "Managed record" : "Registry source",
|
|
29
|
+
href: sourceRef.startsWith("http") ? sourceRef : `/sources/${encodeURIComponent(sourceRef)}`,
|
|
30
|
+
}),
|
|
31
|
+
linkForTraceRef: (ref) => {
|
|
32
|
+
if (ref.kind === "claim") {
|
|
33
|
+
return {
|
|
34
|
+
label: "Claim target",
|
|
35
|
+
href: `/claims/${encodeURIComponent(ref.value)}`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (ref.kind === "candidate-set") {
|
|
39
|
+
return {
|
|
40
|
+
label: "Candidate set",
|
|
41
|
+
href: `/candidate-sets/${encodeURIComponent(ref.value)}`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
export async function buildFacilityCredentialConsumerExample() {
|
|
48
|
+
const reviewedSnapshot = {
|
|
49
|
+
...initialReviewQueueSessionState([facilityCredentialReviewItemFixture]),
|
|
50
|
+
actorId: "review-operator@example.test",
|
|
51
|
+
reviewedAt: "2026-01-17T16:15:00.000Z",
|
|
52
|
+
decisionsByItemName: {
|
|
53
|
+
[facilityCredentialReviewItemFixture.metadata.name]: "accept-proposed",
|
|
54
|
+
},
|
|
55
|
+
notesByItemName: {
|
|
56
|
+
[facilityCredentialReviewItemFixture.metadata.name]: "Registry credential supersedes the managed snapshot.",
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
const eventsToPersist = buildReviewSessionEvents(reviewedSnapshot);
|
|
60
|
+
const persistedEvents = [];
|
|
61
|
+
const persisted = await persistReviewSessionEvents({
|
|
62
|
+
session: reviewedSnapshot,
|
|
63
|
+
events: eventsToPersist,
|
|
64
|
+
expectedEventCount: persistedEvents.length,
|
|
65
|
+
persist: async ({ events, expectedEventCount }) => {
|
|
66
|
+
if (expectedEventCount !== persistedEvents.length) {
|
|
67
|
+
throw new Error(`Expected ${expectedEventCount} persisted events, found ${persistedEvents.length}.`);
|
|
68
|
+
}
|
|
69
|
+
persistedEvents.splice(0, persistedEvents.length, ...events);
|
|
70
|
+
return { eventCount: persistedEvents.length };
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
const applyResult = deriveReviewSessionApplyResultForSnapshot({
|
|
74
|
+
snapshot: reviewedSnapshot,
|
|
75
|
+
events: persisted.events,
|
|
76
|
+
requiredResolvedItems: "all",
|
|
77
|
+
});
|
|
78
|
+
if (!applyResult.ok) {
|
|
79
|
+
throw new Error(`Expected persisted credential events to replay before apply: ${applyResult.issues.map((issue) => issue.message).join(" ")}`);
|
|
80
|
+
}
|
|
81
|
+
const [result] = applyResult.results;
|
|
82
|
+
if (!result) {
|
|
83
|
+
throw new Error("Expected the reviewed credential snapshot to produce one review result.");
|
|
84
|
+
}
|
|
85
|
+
const itemPresentation = buildReviewItemPresentation(facilityCredentialReviewItemFixture, facilityCredentialPresentationAdapter);
|
|
86
|
+
const resultPresentation = buildReviewResultPresentation(result, facilityCredentialReviewItemFixture, facilityCredentialPresentationAdapter);
|
|
87
|
+
const surfaceProjectionPreview = buildSurfaceProjectionPreview(facilityCredentialReviewItemFixture, result.reviewDecision, facilityCredentialPresentationAdapter);
|
|
88
|
+
if (!surfaceProjectionPreview) {
|
|
89
|
+
throw new Error("Expected the reviewed credential result to produce a Surface projection preview.");
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
reviewItem: facilityCredentialReviewItemFixture,
|
|
93
|
+
reviewedSnapshot,
|
|
94
|
+
eventsToPersist,
|
|
95
|
+
persistedEvents: persisted.events,
|
|
96
|
+
persistedEventCount: persisted.eventCount,
|
|
97
|
+
applyResult,
|
|
98
|
+
itemPresentation,
|
|
99
|
+
resultPresentation,
|
|
100
|
+
surfaceProjectionPreview,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
export const facilityCredentialConsumerExample = await buildFacilityCredentialConsumerExample();
|
|
104
|
+
function isCredentialValue(value) {
|
|
105
|
+
return typeof value === "object"
|
|
106
|
+
&& value !== null
|
|
107
|
+
&& "licenseNumber" in value
|
|
108
|
+
&& typeof value.licenseNumber === "string"
|
|
109
|
+
&& "status" in value
|
|
110
|
+
&& typeof value.status === "string"
|
|
111
|
+
&& "expiresAt" in value
|
|
112
|
+
&& typeof value.expiresAt === "string"
|
|
113
|
+
&& "permittedServices" in value
|
|
114
|
+
&& Array.isArray(value.permittedServices)
|
|
115
|
+
&& value.permittedServices.every((service) => typeof service === "string");
|
|
116
|
+
}
|
|
@@ -11,7 +11,7 @@ export interface ReviewWorkbenchSessionExport {
|
|
|
11
11
|
readonly decisions: readonly ReviewDecision[];
|
|
12
12
|
readonly results: readonly ReviewWorkbenchResult[];
|
|
13
13
|
}
|
|
14
|
-
export type ReviewSessionReplayIssueCode = "unknown-active-item" | "unknown-review-item" | "unknown-candidate";
|
|
14
|
+
export type ReviewSessionReplayIssueCode = "unknown-active-item" | "unknown-review-item" | "unknown-candidate" | "missing-review-item" | "invalid-workbench-decision" | "decision-candidate-mismatch" | "decision-status-mismatch";
|
|
15
15
|
export interface ReviewSessionReplayIssue {
|
|
16
16
|
readonly code: ReviewSessionReplayIssueCode;
|
|
17
17
|
readonly eventName: string;
|
|
@@ -20,6 +20,37 @@ export interface ReviewSessionReplayIssue {
|
|
|
20
20
|
readonly candidateId?: string;
|
|
21
21
|
readonly message: string;
|
|
22
22
|
}
|
|
23
|
+
export type ReviewSessionApplyResolutionRequirement = "all" | "any" | "none";
|
|
24
|
+
export type ReviewSessionApplyIssue = ReviewSessionReplayIssue | {
|
|
25
|
+
readonly code: "unresolved-review-item";
|
|
26
|
+
readonly reviewItemName: string;
|
|
27
|
+
readonly message: string;
|
|
28
|
+
} | {
|
|
29
|
+
readonly code: "no-resolved-review-items";
|
|
30
|
+
readonly message: string;
|
|
31
|
+
};
|
|
32
|
+
export interface DeriveReviewSessionApplyResultForSnapshotOptions {
|
|
33
|
+
readonly snapshot: ReviewQueueSessionState;
|
|
34
|
+
readonly events: readonly ReviewSessionEvent[];
|
|
35
|
+
readonly requiredResolvedItems?: ReviewSessionApplyResolutionRequirement;
|
|
36
|
+
}
|
|
37
|
+
export type DeriveReviewSessionApplyResultForSnapshotResult = {
|
|
38
|
+
readonly ok: true;
|
|
39
|
+
readonly issues: readonly [];
|
|
40
|
+
readonly unresolvedItemNames: readonly string[];
|
|
41
|
+
readonly replayedSession: ReviewQueueSessionState;
|
|
42
|
+
readonly sessionExport: ReviewWorkbenchSessionExport;
|
|
43
|
+
readonly decisions: readonly ReviewDecision[];
|
|
44
|
+
readonly results: readonly ReviewWorkbenchResult[];
|
|
45
|
+
} | {
|
|
46
|
+
readonly ok: false;
|
|
47
|
+
readonly issues: readonly ReviewSessionApplyIssue[];
|
|
48
|
+
readonly unresolvedItemNames: readonly string[];
|
|
49
|
+
readonly replayedSession?: ReviewQueueSessionState;
|
|
50
|
+
readonly sessionExport?: ReviewWorkbenchSessionExport;
|
|
51
|
+
readonly decisions: readonly ReviewDecision[];
|
|
52
|
+
readonly results: readonly ReviewWorkbenchResult[];
|
|
53
|
+
};
|
|
23
54
|
export interface ReviewSessionEventStore {
|
|
24
55
|
load(session: ReviewQueueSessionState): readonly ReviewSessionEvent[] | undefined;
|
|
25
56
|
save(session: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): void;
|
|
@@ -36,8 +67,19 @@ export interface ReviewSessionPersistenceRequest {
|
|
|
36
67
|
readonly expectedEventCount: number;
|
|
37
68
|
}
|
|
38
69
|
export interface ReviewSessionPersistenceResult {
|
|
70
|
+
readonly events?: readonly ReviewSessionEvent[];
|
|
39
71
|
readonly eventCount?: number;
|
|
40
72
|
}
|
|
73
|
+
export interface PersistReviewSessionEventsOptions {
|
|
74
|
+
readonly session: ReviewQueueSessionState;
|
|
75
|
+
readonly events: readonly ReviewSessionEvent[];
|
|
76
|
+
readonly expectedEventCount?: number;
|
|
77
|
+
readonly persist: (request: ReviewSessionPersistenceRequest) => Promise<ReviewSessionPersistenceResult | void>;
|
|
78
|
+
}
|
|
79
|
+
export interface PersistReviewSessionEventsResult {
|
|
80
|
+
readonly events: readonly ReviewSessionEvent[];
|
|
81
|
+
readonly eventCount: number;
|
|
82
|
+
}
|
|
41
83
|
export interface PersistentReviewSessionEventStoreOptions {
|
|
42
84
|
readonly initialEvents?: readonly ReviewSessionEvent[];
|
|
43
85
|
readonly persist: (request: ReviewSessionPersistenceRequest) => Promise<ReviewSessionPersistenceResult | void>;
|
|
@@ -69,10 +111,12 @@ export declare function buildReviewWorkbenchSessionExport(session: ReviewQueueSe
|
|
|
69
111
|
export declare function validateReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewSessionReplayIssue[];
|
|
70
112
|
export declare function replayReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewQueueSessionState;
|
|
71
113
|
export declare function buildReviewWorkbenchSessionExportForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewWorkbenchSessionExport;
|
|
114
|
+
export declare function deriveReviewSessionApplyResultForSnapshot(options: DeriveReviewSessionApplyResultForSnapshotOptions): DeriveReviewSessionApplyResultForSnapshotResult;
|
|
72
115
|
export declare function createInMemoryReviewSessionEventStore(initialEvents?: readonly ReviewSessionEvent[]): ReviewSessionEventStore & {
|
|
73
116
|
events(): readonly ReviewSessionEvent[];
|
|
74
117
|
};
|
|
75
118
|
export declare function createLocalStorageReviewSessionEventStore(storage: Pick<Storage, "getItem" | "setItem">, keyPrefix?: string): ReviewSessionEventStore;
|
|
119
|
+
export declare function persistReviewSessionEvents(options: PersistReviewSessionEventsOptions): Promise<PersistReviewSessionEventsResult>;
|
|
76
120
|
export declare function createPersistentReviewSessionEventStore(options: PersistentReviewSessionEventStoreOptions): ReviewSessionEventStore & {
|
|
77
121
|
events(): readonly ReviewSessionEvent[];
|
|
78
122
|
};
|
|
@@ -118,6 +118,52 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
|
|
|
118
118
|
message: `ReviewSessionEvent ${event.metadata.name} references review item ${reviewItemName}, but the supplied session snapshot does not contain that ReviewItem.`,
|
|
119
119
|
});
|
|
120
120
|
}
|
|
121
|
+
if ((event.spec.eventType === "decision-changed" || event.spec.eventType === "decision-submitted")
|
|
122
|
+
&& !reviewItemName) {
|
|
123
|
+
issues.push({
|
|
124
|
+
...eventRef,
|
|
125
|
+
code: "missing-review-item",
|
|
126
|
+
message: `ReviewSessionEvent ${event.metadata.name} is a decision event but does not reference a ReviewItem.`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (event.spec.eventType === "decision-changed" || event.spec.eventType === "decision-submitted") {
|
|
130
|
+
const decision = replayableWorkbenchDecision(event.spec.data?.workbenchDecision);
|
|
131
|
+
if (!decision) {
|
|
132
|
+
issues.push({
|
|
133
|
+
...eventRef,
|
|
134
|
+
code: "invalid-workbench-decision",
|
|
135
|
+
reviewItemName,
|
|
136
|
+
candidateId: event.spec.candidateId,
|
|
137
|
+
message: `ReviewSessionEvent ${event.metadata.name} is a decision event but does not include a replayable workbench decision.`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
else if (itemName && itemsByName.has(itemName)) {
|
|
141
|
+
const item = itemsByName.get(itemName);
|
|
142
|
+
const expectedCandidate = item ? candidateForDecision(item, decision) : undefined;
|
|
143
|
+
const expectedStatus = workbenchDecisionDefinitions[decision].status;
|
|
144
|
+
const referencedCandidateExists = event.spec.candidateId
|
|
145
|
+
? item?.spec.candidates.some((candidate) => candidate.id === event.spec.candidateId)
|
|
146
|
+
: false;
|
|
147
|
+
if (expectedCandidate && (!event.spec.candidateId || referencedCandidateExists) && event.spec.candidateId !== expectedCandidate.id) {
|
|
148
|
+
issues.push({
|
|
149
|
+
...eventRef,
|
|
150
|
+
code: "decision-candidate-mismatch",
|
|
151
|
+
reviewItemName: itemName,
|
|
152
|
+
candidateId: event.spec.candidateId,
|
|
153
|
+
message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects candidate ${expectedCandidate.id}, but references ${event.spec.candidateId ?? "no candidate"}.`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (event.spec.status !== expectedStatus) {
|
|
157
|
+
issues.push({
|
|
158
|
+
...eventRef,
|
|
159
|
+
code: "decision-status-mismatch",
|
|
160
|
+
reviewItemName: itemName,
|
|
161
|
+
candidateId: event.spec.candidateId,
|
|
162
|
+
message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects status ${expectedStatus}, but references ${event.spec.status ?? "no status"}.`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
121
167
|
if (event.spec.candidateId && itemName && itemsByName.has(itemName)) {
|
|
122
168
|
const item = itemsByName.get(itemName);
|
|
123
169
|
const hasCandidate = item?.spec.candidates.some((candidate) => candidate.id === event.spec.candidateId);
|
|
@@ -134,6 +180,11 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
|
|
|
134
180
|
return issues;
|
|
135
181
|
});
|
|
136
182
|
}
|
|
183
|
+
function replayableWorkbenchDecision(value) {
|
|
184
|
+
return typeof value === "string" && value in workbenchDecisionDefinitions
|
|
185
|
+
? value
|
|
186
|
+
: undefined;
|
|
187
|
+
}
|
|
137
188
|
export function replayReviewSessionEventsForSnapshot(snapshot, events) {
|
|
138
189
|
const issues = validateReviewSessionEventsForSnapshot(snapshot, events);
|
|
139
190
|
if (issues.length > 0) {
|
|
@@ -145,6 +196,59 @@ export function buildReviewWorkbenchSessionExportForSnapshot(snapshot, events) {
|
|
|
145
196
|
const replayedSession = replayReviewSessionEventsForSnapshot(snapshot, events);
|
|
146
197
|
return buildReviewWorkbenchSessionExport(replayedSession, events);
|
|
147
198
|
}
|
|
199
|
+
export function deriveReviewSessionApplyResultForSnapshot(options) {
|
|
200
|
+
const requiredResolvedItems = options.requiredResolvedItems ?? "none";
|
|
201
|
+
const replayIssues = validateReviewSessionEventsForSnapshot(options.snapshot, options.events);
|
|
202
|
+
if (replayIssues.length > 0) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
issues: replayIssues,
|
|
206
|
+
unresolvedItemNames: options.snapshot.items.map((item) => item.metadata.name),
|
|
207
|
+
decisions: [],
|
|
208
|
+
results: [],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const replayedSession = replayReviewSessionEvents(options.snapshot, options.events);
|
|
212
|
+
const sessionExport = buildReviewWorkbenchSessionExport(replayedSession, options.events);
|
|
213
|
+
const resolvedItemNames = new Set(sessionExport.results.map((result) => result.reviewItemName));
|
|
214
|
+
const unresolvedItemNames = options.snapshot.items
|
|
215
|
+
.map((item) => item.metadata.name)
|
|
216
|
+
.filter((itemName) => !resolvedItemNames.has(itemName));
|
|
217
|
+
const issues = [];
|
|
218
|
+
if (requiredResolvedItems === "all") {
|
|
219
|
+
issues.push(...unresolvedItemNames.map((reviewItemName) => ({
|
|
220
|
+
code: "unresolved-review-item",
|
|
221
|
+
reviewItemName,
|
|
222
|
+
message: `Review item ${reviewItemName} has no resolved review decision.`,
|
|
223
|
+
})));
|
|
224
|
+
}
|
|
225
|
+
if (requiredResolvedItems === "any" && sessionExport.results.length === 0) {
|
|
226
|
+
issues.push({
|
|
227
|
+
code: "no-resolved-review-items",
|
|
228
|
+
message: "Review session has no resolved review decisions.",
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (issues.length > 0) {
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
issues,
|
|
235
|
+
unresolvedItemNames,
|
|
236
|
+
replayedSession,
|
|
237
|
+
sessionExport,
|
|
238
|
+
decisions: sessionExport.decisions,
|
|
239
|
+
results: sessionExport.results,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
ok: true,
|
|
244
|
+
issues: [],
|
|
245
|
+
unresolvedItemNames,
|
|
246
|
+
replayedSession,
|
|
247
|
+
sessionExport,
|
|
248
|
+
decisions: sessionExport.decisions,
|
|
249
|
+
results: sessionExport.results,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
148
252
|
export function createInMemoryReviewSessionEventStore(initialEvents = []) {
|
|
149
253
|
let savedEvents = [...initialEvents];
|
|
150
254
|
return {
|
|
@@ -175,6 +279,19 @@ export function createLocalStorageReviewSessionEventStore(storage, keyPrefix = r
|
|
|
175
279
|
},
|
|
176
280
|
};
|
|
177
281
|
}
|
|
282
|
+
export async function persistReviewSessionEvents(options) {
|
|
283
|
+
const events = [...options.events];
|
|
284
|
+
const result = await options.persist({
|
|
285
|
+
session: options.session,
|
|
286
|
+
events,
|
|
287
|
+
expectedEventCount: options.expectedEventCount ?? 0,
|
|
288
|
+
});
|
|
289
|
+
const persistedEvents = result?.events ? [...result.events] : events;
|
|
290
|
+
return {
|
|
291
|
+
events: persistedEvents,
|
|
292
|
+
eventCount: result?.eventCount ?? persistedEvents.length,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
178
295
|
export function createPersistentReviewSessionEventStore(options) {
|
|
179
296
|
let savedEvents = [...(options.initialEvents ?? [])];
|
|
180
297
|
let lastPersistedSerialized = JSON.stringify(savedEvents);
|
|
@@ -198,15 +315,16 @@ export function createPersistentReviewSessionEventStore(options) {
|
|
|
198
315
|
return;
|
|
199
316
|
}
|
|
200
317
|
emit({ status: "saving", events: normalizedEvents });
|
|
201
|
-
const result = await
|
|
318
|
+
const result = await persistReviewSessionEvents({
|
|
202
319
|
session,
|
|
203
320
|
events: normalizedEvents,
|
|
204
321
|
expectedEventCount: lastPersistedEventCount,
|
|
322
|
+
persist: options.persist,
|
|
205
323
|
});
|
|
206
|
-
savedEvents =
|
|
207
|
-
lastPersistedSerialized =
|
|
208
|
-
lastPersistedEventCount = result
|
|
209
|
-
emit({ status: "saved", events:
|
|
324
|
+
savedEvents = result.events;
|
|
325
|
+
lastPersistedSerialized = JSON.stringify(result.events);
|
|
326
|
+
lastPersistedEventCount = result.eventCount;
|
|
327
|
+
emit({ status: "saved", events: result.events });
|
|
210
328
|
})
|
|
211
329
|
.catch((error) => {
|
|
212
330
|
emit({ status: "error", events: normalizedEvents, error });
|
package/package.json
CHANGED