@scoutmesh/viewer 0.2.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 +534 -0
- package/dist/cli.js +430 -0
- package/dist/external-browser.js +62 -0
- package/dist/input-error.js +8 -0
- package/dist/launcher.js +39 -0
- package/dist/public/app.js +1157 -0
- package/dist/public/auth.js +55 -0
- package/dist/public/data.js +105 -0
- package/dist/public/index.html +153 -0
- package/dist/public/styles.css +619 -0
- package/dist/server.js +339 -0
- package/dist/state-file.js +48 -0
- package/dist/store.js +437 -0
- package/dist/update-manager.js +448 -0
- package/dist/version.js +1 -0
- package/package.json +20 -0
- package/skills/local-candidate-viewer/SKILL.md +200 -0
package/dist/store.js
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { InputError } from "./input-error.js";
|
|
4
|
+
import { readStateFile, writeStateFile } from "./state-file.js";
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
function isObject(value ) {
|
|
8
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isStrings(value ) {
|
|
12
|
+
return (
|
|
13
|
+
Array.isArray(value) &&
|
|
14
|
+
value.every((item ) => typeof item === "string")
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function object(value ) {
|
|
19
|
+
if (!isObject(value)) {
|
|
20
|
+
throw new InputError("Expected a JSON object.");
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function label(value , field , max = 200) {
|
|
26
|
+
if (typeof value !== "string" || !value.trim() || value.length > max) {
|
|
27
|
+
throw new InputError(
|
|
28
|
+
`${field} must be a nonempty string of at most ${max} characters.`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function strings(value , field ) {
|
|
35
|
+
if (!isStrings(value)) {
|
|
36
|
+
throw new InputError(`${field} must be an array of strings.`);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function layout(value ) {
|
|
42
|
+
const input = object(value);
|
|
43
|
+
if (input.type !== "table" && input.type !== "cards") {
|
|
44
|
+
throw new InputError("layout.type must be table or cards.");
|
|
45
|
+
}
|
|
46
|
+
const columns = strings(input.columns, "layout.columns");
|
|
47
|
+
if (
|
|
48
|
+
columns.length < 1 ||
|
|
49
|
+
columns.length > 12 ||
|
|
50
|
+
new Set(columns).size !== columns.length ||
|
|
51
|
+
columns.some((key) => !/^[a-zA-Z][\w]{0,49}$/u.test(key))
|
|
52
|
+
) {
|
|
53
|
+
throw new InputError("Choose 1–12 unique field names for layout.columns.");
|
|
54
|
+
}
|
|
55
|
+
return { columns, type: input.type };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cellValue(cell , key ) {
|
|
59
|
+
if (
|
|
60
|
+
cell === null ||
|
|
61
|
+
typeof cell === "boolean" ||
|
|
62
|
+
(typeof cell === "number" && Number.isFinite(cell)) ||
|
|
63
|
+
(typeof cell === "string" && cell.length <= 20_000) ||
|
|
64
|
+
(isStrings(cell) &&
|
|
65
|
+
cell.length <= 100 &&
|
|
66
|
+
cell.every((entry) => entry.length <= 2000))
|
|
67
|
+
) {
|
|
68
|
+
return cell;
|
|
69
|
+
}
|
|
70
|
+
throw new InputError(
|
|
71
|
+
`Unsupported value for ${key}. Use scalar values or string arrays.`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function records(value , previous = []) {
|
|
76
|
+
if (!Array.isArray(value) || value.length > 2000) {
|
|
77
|
+
throw new InputError(
|
|
78
|
+
"records must be an array with at most 2,000 candidates."
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const result = new Map(previous.map((record) => [record.id, record]));
|
|
82
|
+
for (const item of value) {
|
|
83
|
+
const input = object(item);
|
|
84
|
+
const id = label(input.id, "Candidate id");
|
|
85
|
+
const prior = result.get(id);
|
|
86
|
+
const merged = {
|
|
87
|
+
...prior,
|
|
88
|
+
id,
|
|
89
|
+
name: label(input.name ?? prior?.name, "Candidate name"),
|
|
90
|
+
};
|
|
91
|
+
for (const [key, cell] of Object.entries(input)) {
|
|
92
|
+
if (
|
|
93
|
+
!/^[a-zA-Z][\w]{0,49}$/u.test(key) ||
|
|
94
|
+
["constructor", "prototype"].includes(key)
|
|
95
|
+
) {
|
|
96
|
+
throw new InputError(
|
|
97
|
+
"Candidate field names must be simple identifiers."
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
merged[key] = cellValue(cell, key);
|
|
101
|
+
}
|
|
102
|
+
label(merged.name, "Candidate name");
|
|
103
|
+
result.set(id, merged);
|
|
104
|
+
}
|
|
105
|
+
if (result.size > 2000) {
|
|
106
|
+
throw new InputError("A prototype view supports at most 2,000 candidates.");
|
|
107
|
+
}
|
|
108
|
+
return [...result.values()];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function savedHistory(pairs ) {
|
|
112
|
+
const history = new Map ();
|
|
113
|
+
for (const pair of pairs) {
|
|
114
|
+
if (
|
|
115
|
+
!Array.isArray(pair) ||
|
|
116
|
+
pair.length !== 2 ||
|
|
117
|
+
typeof pair[0] !== "string" ||
|
|
118
|
+
typeof pair[1] !== "string" ||
|
|
119
|
+
!/^[a-f0-9]{64}$/u.test(pair[1]) ||
|
|
120
|
+
history.has(pair[0])
|
|
121
|
+
) {
|
|
122
|
+
throw new Error("Invalid saved update fingerprint.");
|
|
123
|
+
}
|
|
124
|
+
history.set(label(pair[0], "updateId"), pair[1]);
|
|
125
|
+
}
|
|
126
|
+
return history;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class ViewStore {
|
|
130
|
+
views = new Map ();
|
|
131
|
+
updates = new Map ();
|
|
132
|
+
|
|
133
|
+
stateFile ;
|
|
134
|
+
|
|
135
|
+
constructor(stateFile ) {
|
|
136
|
+
this.stateFile = stateFile;
|
|
137
|
+
if (stateFile) {
|
|
138
|
+
const saved = readStateFile(stateFile);
|
|
139
|
+
if (saved !== undefined) {
|
|
140
|
+
try {
|
|
141
|
+
this.load(saved);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Invalid viewer state in ${stateFile}. The file has not been changed.`,
|
|
145
|
+
{ cause: error }
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
snapshot() {
|
|
153
|
+
return structuredClone({
|
|
154
|
+
updates: [...this.updates].map(([id, history]) => [id, [...history]]),
|
|
155
|
+
version: 1,
|
|
156
|
+
views: [...this.views.values()],
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
load(value ) {
|
|
161
|
+
const input = object(value);
|
|
162
|
+
if (
|
|
163
|
+
input.version !== 1 ||
|
|
164
|
+
!Array.isArray(input.views) ||
|
|
165
|
+
!Array.isArray(input.updates)
|
|
166
|
+
) {
|
|
167
|
+
throw new Error("Unsupported viewer state format.");
|
|
168
|
+
}
|
|
169
|
+
const staging = new ViewStore();
|
|
170
|
+
for (const saved of input.views) {
|
|
171
|
+
const entry = object(saved);
|
|
172
|
+
const view = staging.create(entry);
|
|
173
|
+
staging.select(view.id, entry.state);
|
|
174
|
+
if (
|
|
175
|
+
typeof entry.revision !== "number" ||
|
|
176
|
+
!Number.isSafeInteger(entry.revision) ||
|
|
177
|
+
entry.revision < 1 ||
|
|
178
|
+
typeof entry.updatedAt !== "string" ||
|
|
179
|
+
!Number.isFinite(Date.parse(entry.updatedAt))
|
|
180
|
+
) {
|
|
181
|
+
throw new Error("Invalid saved revision or timestamp.");
|
|
182
|
+
}
|
|
183
|
+
const restored = staging.get(view.id);
|
|
184
|
+
restored.revision = entry.revision;
|
|
185
|
+
restored.updatedAt = entry.updatedAt;
|
|
186
|
+
staging.views.set(view.id, restored);
|
|
187
|
+
}
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
for (const entry of input.updates) {
|
|
190
|
+
if (
|
|
191
|
+
!Array.isArray(entry) ||
|
|
192
|
+
entry.length !== 2 ||
|
|
193
|
+
typeof entry[0] !== "string" ||
|
|
194
|
+
!staging.views.has(entry[0]) ||
|
|
195
|
+
seen.has(entry[0]) ||
|
|
196
|
+
!Array.isArray(entry[1]) ||
|
|
197
|
+
entry[1].length > 500
|
|
198
|
+
) {
|
|
199
|
+
throw new Error("Invalid saved update history.");
|
|
200
|
+
}
|
|
201
|
+
const history = savedHistory(entry[1]);
|
|
202
|
+
staging.updates.set(entry[0], history);
|
|
203
|
+
seen.add(entry[0]);
|
|
204
|
+
}
|
|
205
|
+
this.views = staging.views;
|
|
206
|
+
this.updates = staging.updates;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
commit(views , updates = this.updates) {
|
|
210
|
+
if (this.stateFile) {
|
|
211
|
+
try {
|
|
212
|
+
writeStateFile(this.stateFile, {
|
|
213
|
+
updates: [...updates].map(([id, history]) => [id, [...history]]),
|
|
214
|
+
version: 1,
|
|
215
|
+
views: [...views.values()],
|
|
216
|
+
});
|
|
217
|
+
} catch {
|
|
218
|
+
throw new InputError(
|
|
219
|
+
"Could not save viewer state. The change was not applied. Check the local state file and available disk space.",
|
|
220
|
+
503
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
this.views = views;
|
|
225
|
+
this.updates = updates;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
saveView(view , history ) {
|
|
229
|
+
const views = new Map([...this.views, [view.id, view]]);
|
|
230
|
+
const updates = new Map(this.updates);
|
|
231
|
+
if (history) {
|
|
232
|
+
updates.set(view.id, history);
|
|
233
|
+
}
|
|
234
|
+
this.commit(views, updates);
|
|
235
|
+
return this.get(view.id);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
list() {
|
|
239
|
+
return [...this.views.values()].map(({ id, title, revision, records }) => ({
|
|
240
|
+
count: records.length,
|
|
241
|
+
id,
|
|
242
|
+
revision,
|
|
243
|
+
title,
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
get(id ) {
|
|
248
|
+
const view = this.views.get(id);
|
|
249
|
+
if (!view) {
|
|
250
|
+
throw new InputError(
|
|
251
|
+
"View not found. Check the view ID and configured state file.",
|
|
252
|
+
404
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
return structuredClone(view);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
create(value ) {
|
|
259
|
+
const input = object(value);
|
|
260
|
+
if (this.views.size >= 20) {
|
|
261
|
+
throw new InputError(
|
|
262
|
+
"The prototype supports at most 20 open views.",
|
|
263
|
+
409
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
const id =
|
|
267
|
+
input.id === undefined ? randomUUID() : label(input.id, "View id", 80);
|
|
268
|
+
if (!/^[\w-]+$/u.test(id)) {
|
|
269
|
+
throw new InputError(
|
|
270
|
+
"View id may contain only letters, numbers, underscores, and hyphens."
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (this.views.has(id)) {
|
|
274
|
+
throw new InputError(
|
|
275
|
+
"View already exists. PATCH it to send another batch.",
|
|
276
|
+
409
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
const view = {
|
|
280
|
+
id,
|
|
281
|
+
layout: layout(
|
|
282
|
+
input.layout ?? {
|
|
283
|
+
columns: ["name", "role", "company", "location"],
|
|
284
|
+
type: "table",
|
|
285
|
+
}
|
|
286
|
+
),
|
|
287
|
+
records: records(input.records ?? []),
|
|
288
|
+
revision: 1,
|
|
289
|
+
state: {
|
|
290
|
+
filter: "",
|
|
291
|
+
maybeIds: [],
|
|
292
|
+
removedIds: [],
|
|
293
|
+
selectedIds: [],
|
|
294
|
+
sortBy: "name",
|
|
295
|
+
sortDirection: "asc",
|
|
296
|
+
},
|
|
297
|
+
title: label(input.title, "Title"),
|
|
298
|
+
updatedAt: new Date().toISOString(),
|
|
299
|
+
};
|
|
300
|
+
return this.saveView(view, new Map());
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
update(id , value ) {
|
|
304
|
+
const input = object(value);
|
|
305
|
+
const current = this.get(id);
|
|
306
|
+
const updateId = label(input.updateId, "updateId");
|
|
307
|
+
const fingerprint = createHash("sha256")
|
|
308
|
+
.update(JSON.stringify(input))
|
|
309
|
+
.digest("hex");
|
|
310
|
+
const priorHistory = this.updates.get(id);
|
|
311
|
+
if (!priorHistory) {
|
|
312
|
+
throw new InputError("View not found.", 404);
|
|
313
|
+
}
|
|
314
|
+
const history = new Map(priorHistory);
|
|
315
|
+
if (history.has(updateId)) {
|
|
316
|
+
if (history.get(updateId) !== fingerprint) {
|
|
317
|
+
throw new InputError(
|
|
318
|
+
"This updateId was already used with a different payload.",
|
|
319
|
+
409
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return current;
|
|
323
|
+
}
|
|
324
|
+
if (
|
|
325
|
+
input.expectedRevision !== undefined &&
|
|
326
|
+
input.expectedRevision !== current.revision
|
|
327
|
+
) {
|
|
328
|
+
throw new InputError(
|
|
329
|
+
`Revision conflict. Current revision is ${current.revision}.`,
|
|
330
|
+
409
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
if (input.operation === "upsert") {
|
|
334
|
+
current.records = records(input.records, current.records);
|
|
335
|
+
} else if (input.operation === "replace") {
|
|
336
|
+
current.records = records(input.records);
|
|
337
|
+
} else if (input.operation === "remove") {
|
|
338
|
+
const ids = new Set(strings(input.ids, "ids"));
|
|
339
|
+
current.records = current.records.filter((record) => !ids.has(record.id));
|
|
340
|
+
} else if (input.operation !== "set_layout") {
|
|
341
|
+
throw new InputError(
|
|
342
|
+
"operation must be upsert, replace, remove, or set_layout."
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
if (input.layout !== undefined || input.operation === "set_layout") {
|
|
346
|
+
current.layout = layout(input.layout);
|
|
347
|
+
}
|
|
348
|
+
if (input.title !== undefined) {
|
|
349
|
+
current.title = label(input.title, "Title");
|
|
350
|
+
}
|
|
351
|
+
const ids = new Set(current.records.map((record) => record.id));
|
|
352
|
+
current.state.selectedIds = current.state.selectedIds.filter((id) =>
|
|
353
|
+
ids.has(id)
|
|
354
|
+
);
|
|
355
|
+
current.state.maybeIds = current.state.maybeIds.filter((id) => ids.has(id));
|
|
356
|
+
current.state.removedIds = current.state.removedIds.filter((id) =>
|
|
357
|
+
ids.has(id)
|
|
358
|
+
);
|
|
359
|
+
current.revision += 1;
|
|
360
|
+
current.updatedAt = new Date().toISOString();
|
|
361
|
+
history.set(updateId, fingerprint);
|
|
362
|
+
if (history.size > 500) {
|
|
363
|
+
const oldest = history.keys().next().value;
|
|
364
|
+
if (oldest !== undefined) {
|
|
365
|
+
history.delete(oldest);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return this.saveView(current, history);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
select(id , value ) {
|
|
372
|
+
const input = object(value);
|
|
373
|
+
const current = this.get(id);
|
|
374
|
+
const ids = new Set(current.records.map((record) => record.id));
|
|
375
|
+
const selectedIds = [...new Set(strings(input.selectedIds, "selectedIds"))];
|
|
376
|
+
const removedIds = [
|
|
377
|
+
...new Set(
|
|
378
|
+
strings(input.removedIds ?? current.state.removedIds, "removedIds")
|
|
379
|
+
),
|
|
380
|
+
];
|
|
381
|
+
const maybeIds = [
|
|
382
|
+
...new Set(strings(input.maybeIds ?? current.state.maybeIds, "maybeIds")),
|
|
383
|
+
];
|
|
384
|
+
if (
|
|
385
|
+
[...selectedIds, ...removedIds, ...maybeIds].some((id) => !ids.has(id))
|
|
386
|
+
) {
|
|
387
|
+
throw new InputError(
|
|
388
|
+
"Selection includes a candidate that is no longer in this view.",
|
|
389
|
+
409
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
if (
|
|
393
|
+
typeof input.filter !== "string" ||
|
|
394
|
+
input.filter.length > 500 ||
|
|
395
|
+
typeof input.sortBy !== "string" ||
|
|
396
|
+
input.sortBy.length > 50 ||
|
|
397
|
+
(input.sortDirection !== "asc" && input.sortDirection !== "desc")
|
|
398
|
+
) {
|
|
399
|
+
throw new InputError("Invalid filter or sort state.");
|
|
400
|
+
}
|
|
401
|
+
if (selectedIds.some((id) => removedIds.includes(id))) {
|
|
402
|
+
throw new InputError(
|
|
403
|
+
"A removed candidate cannot also be shortlisted.",
|
|
404
|
+
409
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
if (
|
|
408
|
+
maybeIds.some(
|
|
409
|
+
(candidateId) =>
|
|
410
|
+
selectedIds.includes(candidateId) || removedIds.includes(candidateId)
|
|
411
|
+
)
|
|
412
|
+
) {
|
|
413
|
+
throw new InputError(
|
|
414
|
+
"A candidate can have only one review decision: shortlisted, maybe, or removed.",
|
|
415
|
+
409
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
current.state = {
|
|
419
|
+
filter: input.filter,
|
|
420
|
+
maybeIds,
|
|
421
|
+
removedIds,
|
|
422
|
+
selectedIds,
|
|
423
|
+
sortBy: input.sortBy,
|
|
424
|
+
sortDirection: input.sortDirection,
|
|
425
|
+
};
|
|
426
|
+
return this.saveView(current);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
delete(id ) {
|
|
430
|
+
this.get(id);
|
|
431
|
+
const views = new Map(this.views);
|
|
432
|
+
const updates = new Map(this.updates);
|
|
433
|
+
views.delete(id);
|
|
434
|
+
updates.delete(id);
|
|
435
|
+
this.commit(views, updates);
|
|
436
|
+
}
|
|
437
|
+
}
|