@kontourai/survey 0.4.17 → 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 +5 -2
- package/dist/examples/review-workbench/facility-credential-consumer.d.ts +4 -2
- package/dist/examples/review-workbench/facility-credential-consumer.js +11 -4
- package/dist/src/review-workbench/review-workbench.d.ts +33 -1
- package/dist/src/review-workbench/review-workbench.js +104 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -103,8 +103,11 @@ review events, exported results, and optional Surface projection, see
|
|
|
103
103
|
That guide also covers the server-side apply boundary: producers should derive
|
|
104
104
|
write results from reviewed snapshots plus persisted events, not from
|
|
105
105
|
browser-computed decisions or exported result payloads.
|
|
106
|
-
Use `persistReviewSessionEvents` when server code needs to save review events
|
|
107
|
-
|
|
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.
|
|
108
111
|
For a generic, test-covered example of the consumer adapter contract, see
|
|
109
112
|
[`examples/review-workbench/facility-credential-consumer.ts`](examples/review-workbench/facility-credential-consumer.ts).
|
|
110
113
|
For the current decision on why Survey is not adding a generic review adapter
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { facilityCredentialReviewItemFixture } from "../../src/review-workbench/review-workbench-data.js";
|
|
2
|
-
import { buildReviewItemPresentation, buildReviewResultPresentation,
|
|
2
|
+
import { buildReviewItemPresentation, buildReviewResultPresentation, buildSurfaceProjectionPreview, deriveReviewSessionApplyResultForSnapshot, initialReviewQueueSessionState, type ReviewPresentationAdapter } from "../../src/review-workbench/review-workbench.js";
|
|
3
3
|
import type { ReviewSessionEvent } from "../../src/review-resource.js";
|
|
4
4
|
export declare const facilityCredentialPresentationAdapter: ReviewPresentationAdapter;
|
|
5
5
|
export declare function buildFacilityCredentialConsumerExample(): Promise<FacilityCredentialConsumerExample>;
|
|
@@ -10,7 +10,9 @@ export interface FacilityCredentialConsumerExample {
|
|
|
10
10
|
readonly eventsToPersist: readonly ReviewSessionEvent[];
|
|
11
11
|
readonly persistedEvents: readonly ReviewSessionEvent[];
|
|
12
12
|
readonly persistedEventCount: number;
|
|
13
|
-
readonly
|
|
13
|
+
readonly applyResult: Extract<ReturnType<typeof deriveReviewSessionApplyResultForSnapshot>, {
|
|
14
|
+
readonly ok: true;
|
|
15
|
+
}>;
|
|
14
16
|
readonly itemPresentation: ReturnType<typeof buildReviewItemPresentation>;
|
|
15
17
|
readonly resultPresentation: ReturnType<typeof buildReviewResultPresentation>;
|
|
16
18
|
readonly surfaceProjectionPreview: NonNullable<ReturnType<typeof buildSurfaceProjectionPreview>>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { facilityCredentialReviewItemFixture } from "../../src/review-workbench/review-workbench-data.js";
|
|
2
|
-
import { buildReviewItemPresentation, buildReviewResultPresentation, buildReviewSessionEvents,
|
|
2
|
+
import { buildReviewItemPresentation, buildReviewResultPresentation, buildReviewSessionEvents, buildSurfaceProjectionPreview, deriveReviewSessionApplyResultForSnapshot, initialReviewQueueSessionState, persistReviewSessionEvents, } from "../../src/review-workbench/review-workbench.js";
|
|
3
3
|
export const facilityCredentialPresentationAdapter = {
|
|
4
4
|
labelForTarget: (target) => target === "operatingLicenseCredential"
|
|
5
5
|
? "Operating license credential"
|
|
@@ -70,8 +70,15 @@ export async function buildFacilityCredentialConsumerExample() {
|
|
|
70
70
|
return { eventCount: persistedEvents.length };
|
|
71
71
|
},
|
|
72
72
|
});
|
|
73
|
-
const
|
|
74
|
-
|
|
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;
|
|
75
82
|
if (!result) {
|
|
76
83
|
throw new Error("Expected the reviewed credential snapshot to produce one review result.");
|
|
77
84
|
}
|
|
@@ -87,7 +94,7 @@ export async function buildFacilityCredentialConsumerExample() {
|
|
|
87
94
|
eventsToPersist,
|
|
88
95
|
persistedEvents: persisted.events,
|
|
89
96
|
persistedEventCount: persisted.eventCount,
|
|
90
|
-
|
|
97
|
+
applyResult,
|
|
91
98
|
itemPresentation,
|
|
92
99
|
resultPresentation,
|
|
93
100
|
surfaceProjectionPreview,
|
|
@@ -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;
|
|
@@ -80,6 +111,7 @@ export declare function buildReviewWorkbenchSessionExport(session: ReviewQueueSe
|
|
|
80
111
|
export declare function validateReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewSessionReplayIssue[];
|
|
81
112
|
export declare function replayReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewQueueSessionState;
|
|
82
113
|
export declare function buildReviewWorkbenchSessionExportForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewWorkbenchSessionExport;
|
|
114
|
+
export declare function deriveReviewSessionApplyResultForSnapshot(options: DeriveReviewSessionApplyResultForSnapshotOptions): DeriveReviewSessionApplyResultForSnapshotResult;
|
|
83
115
|
export declare function createInMemoryReviewSessionEventStore(initialEvents?: readonly ReviewSessionEvent[]): ReviewSessionEventStore & {
|
|
84
116
|
events(): readonly ReviewSessionEvent[];
|
|
85
117
|
};
|
|
@@ -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 {
|
package/package.json
CHANGED