@kontourai/survey 0.4.16 → 0.4.17
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 +16 -2
- package/dist/examples/review-workbench/facility-credential-consumer.d.ts +17 -0
- package/dist/examples/review-workbench/facility-credential-consumer.js +109 -0
- package/dist/src/review-workbench/review-workbench.d.ts +12 -0
- package/dist/src/review-workbench/review-workbench.js +19 -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,10 @@ 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
|
+
and then replay exactly the persisted event set before applying product policy.
|
|
108
|
+
For a generic, test-covered example of the consumer adapter contract, see
|
|
109
|
+
[`examples/review-workbench/facility-credential-consumer.ts`](examples/review-workbench/facility-credential-consumer.ts).
|
|
96
110
|
For the current decision on why Survey is not adding a generic review adapter
|
|
97
111
|
builder yet, see
|
|
98
112
|
[`docs/consumer-adapter-abstraction-assessment.md`](docs/consumer-adapter-abstraction-assessment.md).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { facilityCredentialReviewItemFixture } from "../../src/review-workbench/review-workbench-data.js";
|
|
2
|
+
import { buildReviewItemPresentation, buildReviewResultPresentation, buildReviewWorkbenchSessionExportForSnapshot, buildSurfaceProjectionPreview, 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 sessionExport: ReturnType<typeof buildReviewWorkbenchSessionExportForSnapshot>;
|
|
14
|
+
readonly itemPresentation: ReturnType<typeof buildReviewItemPresentation>;
|
|
15
|
+
readonly resultPresentation: ReturnType<typeof buildReviewResultPresentation>;
|
|
16
|
+
readonly surfaceProjectionPreview: NonNullable<ReturnType<typeof buildSurfaceProjectionPreview>>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { facilityCredentialReviewItemFixture } from "../../src/review-workbench/review-workbench-data.js";
|
|
2
|
+
import { buildReviewItemPresentation, buildReviewResultPresentation, buildReviewSessionEvents, buildReviewWorkbenchSessionExportForSnapshot, buildSurfaceProjectionPreview, 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 sessionExport = buildReviewWorkbenchSessionExportForSnapshot(reviewedSnapshot, persisted.events);
|
|
74
|
+
const [result] = sessionExport.results;
|
|
75
|
+
if (!result) {
|
|
76
|
+
throw new Error("Expected the reviewed credential snapshot to produce one review result.");
|
|
77
|
+
}
|
|
78
|
+
const itemPresentation = buildReviewItemPresentation(facilityCredentialReviewItemFixture, facilityCredentialPresentationAdapter);
|
|
79
|
+
const resultPresentation = buildReviewResultPresentation(result, facilityCredentialReviewItemFixture, facilityCredentialPresentationAdapter);
|
|
80
|
+
const surfaceProjectionPreview = buildSurfaceProjectionPreview(facilityCredentialReviewItemFixture, result.reviewDecision, facilityCredentialPresentationAdapter);
|
|
81
|
+
if (!surfaceProjectionPreview) {
|
|
82
|
+
throw new Error("Expected the reviewed credential result to produce a Surface projection preview.");
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
reviewItem: facilityCredentialReviewItemFixture,
|
|
86
|
+
reviewedSnapshot,
|
|
87
|
+
eventsToPersist,
|
|
88
|
+
persistedEvents: persisted.events,
|
|
89
|
+
persistedEventCount: persisted.eventCount,
|
|
90
|
+
sessionExport,
|
|
91
|
+
itemPresentation,
|
|
92
|
+
resultPresentation,
|
|
93
|
+
surfaceProjectionPreview,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export const facilityCredentialConsumerExample = await buildFacilityCredentialConsumerExample();
|
|
97
|
+
function isCredentialValue(value) {
|
|
98
|
+
return typeof value === "object"
|
|
99
|
+
&& value !== null
|
|
100
|
+
&& "licenseNumber" in value
|
|
101
|
+
&& typeof value.licenseNumber === "string"
|
|
102
|
+
&& "status" in value
|
|
103
|
+
&& typeof value.status === "string"
|
|
104
|
+
&& "expiresAt" in value
|
|
105
|
+
&& typeof value.expiresAt === "string"
|
|
106
|
+
&& "permittedServices" in value
|
|
107
|
+
&& Array.isArray(value.permittedServices)
|
|
108
|
+
&& value.permittedServices.every((service) => typeof service === "string");
|
|
109
|
+
}
|
|
@@ -36,8 +36,19 @@ export interface ReviewSessionPersistenceRequest {
|
|
|
36
36
|
readonly expectedEventCount: number;
|
|
37
37
|
}
|
|
38
38
|
export interface ReviewSessionPersistenceResult {
|
|
39
|
+
readonly events?: readonly ReviewSessionEvent[];
|
|
39
40
|
readonly eventCount?: number;
|
|
40
41
|
}
|
|
42
|
+
export interface PersistReviewSessionEventsOptions {
|
|
43
|
+
readonly session: ReviewQueueSessionState;
|
|
44
|
+
readonly events: readonly ReviewSessionEvent[];
|
|
45
|
+
readonly expectedEventCount?: number;
|
|
46
|
+
readonly persist: (request: ReviewSessionPersistenceRequest) => Promise<ReviewSessionPersistenceResult | void>;
|
|
47
|
+
}
|
|
48
|
+
export interface PersistReviewSessionEventsResult {
|
|
49
|
+
readonly events: readonly ReviewSessionEvent[];
|
|
50
|
+
readonly eventCount: number;
|
|
51
|
+
}
|
|
41
52
|
export interface PersistentReviewSessionEventStoreOptions {
|
|
42
53
|
readonly initialEvents?: readonly ReviewSessionEvent[];
|
|
43
54
|
readonly persist: (request: ReviewSessionPersistenceRequest) => Promise<ReviewSessionPersistenceResult | void>;
|
|
@@ -73,6 +84,7 @@ export declare function createInMemoryReviewSessionEventStore(initialEvents?: re
|
|
|
73
84
|
events(): readonly ReviewSessionEvent[];
|
|
74
85
|
};
|
|
75
86
|
export declare function createLocalStorageReviewSessionEventStore(storage: Pick<Storage, "getItem" | "setItem">, keyPrefix?: string): ReviewSessionEventStore;
|
|
87
|
+
export declare function persistReviewSessionEvents(options: PersistReviewSessionEventsOptions): Promise<PersistReviewSessionEventsResult>;
|
|
76
88
|
export declare function createPersistentReviewSessionEventStore(options: PersistentReviewSessionEventStoreOptions): ReviewSessionEventStore & {
|
|
77
89
|
events(): readonly ReviewSessionEvent[];
|
|
78
90
|
};
|
|
@@ -175,6 +175,19 @@ export function createLocalStorageReviewSessionEventStore(storage, keyPrefix = r
|
|
|
175
175
|
},
|
|
176
176
|
};
|
|
177
177
|
}
|
|
178
|
+
export async function persistReviewSessionEvents(options) {
|
|
179
|
+
const events = [...options.events];
|
|
180
|
+
const result = await options.persist({
|
|
181
|
+
session: options.session,
|
|
182
|
+
events,
|
|
183
|
+
expectedEventCount: options.expectedEventCount ?? 0,
|
|
184
|
+
});
|
|
185
|
+
const persistedEvents = result?.events ? [...result.events] : events;
|
|
186
|
+
return {
|
|
187
|
+
events: persistedEvents,
|
|
188
|
+
eventCount: result?.eventCount ?? persistedEvents.length,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
178
191
|
export function createPersistentReviewSessionEventStore(options) {
|
|
179
192
|
let savedEvents = [...(options.initialEvents ?? [])];
|
|
180
193
|
let lastPersistedSerialized = JSON.stringify(savedEvents);
|
|
@@ -198,15 +211,16 @@ export function createPersistentReviewSessionEventStore(options) {
|
|
|
198
211
|
return;
|
|
199
212
|
}
|
|
200
213
|
emit({ status: "saving", events: normalizedEvents });
|
|
201
|
-
const result = await
|
|
214
|
+
const result = await persistReviewSessionEvents({
|
|
202
215
|
session,
|
|
203
216
|
events: normalizedEvents,
|
|
204
217
|
expectedEventCount: lastPersistedEventCount,
|
|
218
|
+
persist: options.persist,
|
|
205
219
|
});
|
|
206
|
-
savedEvents =
|
|
207
|
-
lastPersistedSerialized =
|
|
208
|
-
lastPersistedEventCount = result
|
|
209
|
-
emit({ status: "saved", events:
|
|
220
|
+
savedEvents = result.events;
|
|
221
|
+
lastPersistedSerialized = JSON.stringify(result.events);
|
|
222
|
+
lastPersistedEventCount = result.eventCount;
|
|
223
|
+
emit({ status: "saved", events: result.events });
|
|
210
224
|
})
|
|
211
225
|
.catch((error) => {
|
|
212
226
|
emit({ status: "error", events: normalizedEvents, error });
|
package/package.json
CHANGED