@larc-iu/plaid-client 0.2.0-alpha.17 → 0.2.0-alpha.18
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/index.d.ts +124 -5
- package/package.json +1 -1
- package/src/documentLock.js +14 -9
- package/src/events.js +256 -0
- package/src/http.js +15 -0
- package/src/index.js +175 -16
- package/src/provenance.js +5 -1
- package/src/requester.js +56 -0
- package/src/services.js +20 -2
package/index.d.ts
CHANGED
|
@@ -106,6 +106,26 @@ export class ServiceCancelled extends Error {
|
|
|
106
106
|
constructor(message?: string);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** The key a run's stored record names its requester under. */
|
|
110
|
+
export const REQUESTED_BY: "requestedBy";
|
|
111
|
+
|
|
112
|
+
/** The person who asked for one service run. Both fields are null when nobody asked. */
|
|
113
|
+
export interface Requester {
|
|
114
|
+
id: string | null;
|
|
115
|
+
name: string | null;
|
|
116
|
+
/** `"<text>, requested by <name>"`, or `text` when nobody asked. */
|
|
117
|
+
label(text: string): string;
|
|
118
|
+
/** `{ id, name }` for a stored report, or null when nobody asked. */
|
|
119
|
+
record(): { id: string; name: string | null } | null;
|
|
120
|
+
/** A machine stamp's `provDetail` with `requestedBy: <id>` added. */
|
|
121
|
+
detail(detail?: Record<string, any>): Record<string, any>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function makeRequester(id?: string | null, name?: string | null): Requester;
|
|
125
|
+
|
|
126
|
+
/** The requester of one request, from the `requesterId` in a `serve` handler's data, named by display name. */
|
|
127
|
+
export function requesterOf(client: { users: { get(id: string): Promise<any> } }, data: any): Promise<Requester>;
|
|
128
|
+
|
|
109
129
|
/** The cancellation half of a responseHelper, on its own. */
|
|
110
130
|
export function createCancelScope(isCancelled: () => boolean): {
|
|
111
131
|
readonly cancelled: boolean;
|
|
@@ -471,6 +491,65 @@ interface Comment {
|
|
|
471
491
|
edited: boolean;
|
|
472
492
|
}
|
|
473
493
|
|
|
494
|
+
/** A research-telemetry event type (core manual, "Research telemetry"). */
|
|
495
|
+
type ClientEventType =
|
|
496
|
+
| "suggestion.shown"
|
|
497
|
+
| "suggestion.adopted"
|
|
498
|
+
| "suggestion.dismissed"
|
|
499
|
+
| "plan.opened";
|
|
500
|
+
|
|
501
|
+
interface ClientEventFields {
|
|
502
|
+
projectId: string;
|
|
503
|
+
documentId?: string;
|
|
504
|
+
targetId?: string;
|
|
505
|
+
/** Keyed by single lowercase words: value, source, field, written, conversation. */
|
|
506
|
+
data?: Record<string, any>;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
interface ClientEvent {
|
|
510
|
+
id: number;
|
|
511
|
+
projectId: string;
|
|
512
|
+
documentId: string | null;
|
|
513
|
+
userId: string;
|
|
514
|
+
type: ClientEventType;
|
|
515
|
+
targetId: string | null;
|
|
516
|
+
data: Record<string, any> | null;
|
|
517
|
+
clientTs: string | null;
|
|
518
|
+
ts: string;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
interface ClientEventFilters {
|
|
522
|
+
types?: ClientEventType | ClientEventType[] | string;
|
|
523
|
+
startTime?: string;
|
|
524
|
+
endTime?: string;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
interface EventsBundle {
|
|
528
|
+
/** Buffer one event; never throws. False when not recorded (switch off, a repeat shown). */
|
|
529
|
+
record(type: ClientEventType, fields: ClientEventFields): boolean;
|
|
530
|
+
flush(): void;
|
|
531
|
+
setEnabled(projectId: string, on: boolean): void;
|
|
532
|
+
create(
|
|
533
|
+
projectId: string,
|
|
534
|
+
events: Array<{
|
|
535
|
+
type: ClientEventType;
|
|
536
|
+
documentId?: string;
|
|
537
|
+
targetId?: string;
|
|
538
|
+
data?: Record<string, any>;
|
|
539
|
+
clientTs?: string;
|
|
540
|
+
}>,
|
|
541
|
+
): Promise<{ count: number }>;
|
|
542
|
+
list(projectId: string, filters?: ClientEventFilters): Promise<ClientEvent[]>;
|
|
543
|
+
listPage(
|
|
544
|
+
projectId: string,
|
|
545
|
+
opts?: ClientEventFilters & { limit?: number; cursor?: string },
|
|
546
|
+
): Promise<Page<ClientEvent>>;
|
|
547
|
+
iterPages(
|
|
548
|
+
projectId: string,
|
|
549
|
+
opts?: ClientEventFilters & { pageSize?: number },
|
|
550
|
+
): AsyncGenerator<ClientEvent[]>;
|
|
551
|
+
}
|
|
552
|
+
|
|
474
553
|
interface CommentsBundle {
|
|
475
554
|
create(
|
|
476
555
|
entityType: CommentableType,
|
|
@@ -889,6 +968,8 @@ interface TokenLayersBundle {
|
|
|
889
968
|
/** Handle passed to a `documents.locked()` block. */
|
|
890
969
|
export declare class DocumentLock {
|
|
891
970
|
readonly documentId: string;
|
|
971
|
+
/** The holder id the acquire answered with. */
|
|
972
|
+
readonly lockId: string | null;
|
|
892
973
|
/** The DocumentLockLost if the lock lapsed, else null. */
|
|
893
974
|
readonly lost: DocumentLockLost | null;
|
|
894
975
|
/** Throw if the lock lapsed; call it between steps that have not written. */
|
|
@@ -907,12 +988,23 @@ export const DOCUMENT_LOCK_TTL_MS: 60000;
|
|
|
907
988
|
|
|
908
989
|
interface DocumentsBundle {
|
|
909
990
|
checkLock(documentId: string): Promise<any>;
|
|
991
|
+
/** Take the lock as a new holder. The answer's `lockId` names it. */
|
|
910
992
|
acquireLock(documentId: string, auditMessage?: string): Promise<any>;
|
|
911
|
-
|
|
993
|
+
/** Renew `lockId`'s lock while it is live. 423 once it has lapsed. */
|
|
994
|
+
renewLock(
|
|
995
|
+
documentId: string,
|
|
996
|
+
lockId: string,
|
|
997
|
+
auditMessage?: string,
|
|
998
|
+
): Promise<any>;
|
|
999
|
+
releaseLock(
|
|
1000
|
+
documentId: string,
|
|
1001
|
+
lockId: string,
|
|
1002
|
+
auditMessage?: string,
|
|
1003
|
+
): Promise<any>;
|
|
912
1004
|
/**
|
|
913
1005
|
* Hold the document's lock for the length of `fn`, renewing it while `fn`
|
|
914
|
-
* runs and releasing it on the way out. Rejects with a 423 if
|
|
915
|
-
*
|
|
1006
|
+
* runs and releasing it on the way out. Rejects with a 423 if it is already
|
|
1007
|
+
* held, and with DocumentLockLost if a renewal fails.
|
|
916
1008
|
*/
|
|
917
1009
|
locked<T>(
|
|
918
1010
|
documentId: string,
|
|
@@ -1027,6 +1119,8 @@ interface MessagesBundle {
|
|
|
1027
1119
|
onAccepted?: (requestId: string) => void;
|
|
1028
1120
|
/** Other projects the request is about. A delegating service's token reaches `projectId` and those of these the requester can read, nothing else. */
|
|
1029
1121
|
projectIds?: string[];
|
|
1122
|
+
/** Carry no open operation: the service's writes are a group of their own, not part of whatever operation this client has open. */
|
|
1123
|
+
noOperation?: boolean;
|
|
1030
1124
|
},
|
|
1031
1125
|
): Promise<any>;
|
|
1032
1126
|
/**
|
|
@@ -1256,6 +1350,15 @@ interface PlaidClientOptions {
|
|
|
1256
1350
|
onAuthError?: ((error: Error) => void) | null;
|
|
1257
1351
|
}
|
|
1258
1352
|
|
|
1353
|
+
/** What kind of operation a logical-operation group is (`?group-kind=`). */
|
|
1354
|
+
export type OperationKind =
|
|
1355
|
+
| 'assistant-plan'
|
|
1356
|
+
| 'service-run'
|
|
1357
|
+
| 'import'
|
|
1358
|
+
| 'bulk-edit'
|
|
1359
|
+
| 'guess-adoption'
|
|
1360
|
+
| 'repair';
|
|
1361
|
+
|
|
1259
1362
|
export interface OperationGroupsBundle {
|
|
1260
1363
|
get(id: string): Promise<any>;
|
|
1261
1364
|
update(id: string, message: string | null): Promise<any>;
|
|
@@ -1332,6 +1435,10 @@ export declare class PlaidClient {
|
|
|
1332
1435
|
documentVersions: Record<string, number>;
|
|
1333
1436
|
/** True once a response left its version list out (X-Document-Versions-Omitted, past fifty documents). Every version held was then forgotten, and the strict-mode document's is read again before the next write. */
|
|
1334
1437
|
documentVersionsOmitted?: boolean;
|
|
1438
|
+
/** The server's clock minus this machine's, in ms, from the last response with a Date header, or null before one. */
|
|
1439
|
+
serverClockOffsetMs: number | null;
|
|
1440
|
+
/** The server's time now, from its last response's Date header (to the second), else this machine's. Judge a time the server stamped, such as an audit entry's `ts`, against this rather than `Date.now()`. */
|
|
1441
|
+
serverNow(): Date;
|
|
1335
1442
|
|
|
1336
1443
|
// Batches.
|
|
1337
1444
|
//
|
|
@@ -1355,12 +1462,23 @@ export declare class PlaidClient {
|
|
|
1355
1462
|
// Logical operations (audit-log grouping). While one is open every write is
|
|
1356
1463
|
// stamped with its group id so the audit log folds them into one entry.
|
|
1357
1464
|
// Not atomic; nesting flattens into the outer operation.
|
|
1358
|
-
|
|
1359
|
-
|
|
1465
|
+
// `kind` is one of OperationKind (the server refuses any other) and `ref`
|
|
1466
|
+
// names what the operation came from; nesting keeps the outer ones.
|
|
1467
|
+
operationGroup: {
|
|
1468
|
+
id: string;
|
|
1469
|
+
message: string | null;
|
|
1470
|
+
kind: OperationKind | null;
|
|
1471
|
+
ref: string | null;
|
|
1472
|
+
} | null;
|
|
1473
|
+
beginOperation(
|
|
1474
|
+
message: string,
|
|
1475
|
+
opts?: { id?: string; kind?: OperationKind; ref?: string },
|
|
1476
|
+
): string;
|
|
1360
1477
|
endOperation(message?: string): Promise<void>;
|
|
1361
1478
|
withOperation<T>(
|
|
1362
1479
|
message: string,
|
|
1363
1480
|
fn: (setMessage: (msg: string) => void) => Promise<T> | T,
|
|
1481
|
+
opts?: { kind?: OperationKind; ref?: string },
|
|
1364
1482
|
): Promise<T>;
|
|
1365
1483
|
operationGroups: OperationGroupsBundle;
|
|
1366
1484
|
|
|
@@ -1382,6 +1500,7 @@ export declare class PlaidClient {
|
|
|
1382
1500
|
admin: AdminBundle;
|
|
1383
1501
|
audit: AuditBundle;
|
|
1384
1502
|
comments: CommentsBundle;
|
|
1503
|
+
events: EventsBundle;
|
|
1385
1504
|
guidelines: GuidelinesBundle;
|
|
1386
1505
|
tokenLayers: TokenLayersBundle;
|
|
1387
1506
|
documents: DocumentsBundle;
|
package/package.json
CHANGED
package/src/documentLock.js
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* silence, a person's edit lands between the read the work was planned from and
|
|
11
11
|
* the write about to go out, and the write clobbers it.
|
|
12
12
|
*
|
|
13
|
-
* A `locked()` block therefore renews on a timer of its own. The renewal is
|
|
14
|
-
*
|
|
13
|
+
* A `locked()` block therefore renews on a timer of its own. The renewal is an
|
|
14
|
+
* acquire that names the holder's `lockId`: the server refreshes the lock that
|
|
15
|
+
* id holds, and refuses one somebody else holds (the same user included).
|
|
15
16
|
*
|
|
16
17
|
* The beat is unconditional rather than "only when no write has gone out
|
|
17
18
|
* recently". The request layer knows the method of a call but not which
|
|
@@ -157,9 +158,10 @@ export class LockKeeper {
|
|
|
157
158
|
try {
|
|
158
159
|
await this._refresh(this._documentId);
|
|
159
160
|
} catch (error) {
|
|
160
|
-
// 423 is definitive: somebody else holds the document now,
|
|
161
|
-
//
|
|
162
|
-
//
|
|
161
|
+
// 423 is definitive: somebody else holds the document now, or ours
|
|
162
|
+
// expired or was dropped, and the server never takes it back.
|
|
163
|
+
// Anything else may be a blip, and is only fatal once the lock we are
|
|
164
|
+
// renewing has actually run out.
|
|
163
165
|
if (error?.status === 423 || this._clock() >= deadline) {
|
|
164
166
|
this._fail(error);
|
|
165
167
|
return;
|
|
@@ -203,8 +205,10 @@ export class LockKeeper {
|
|
|
203
205
|
* between steps of work that has not written anything yet.
|
|
204
206
|
*/
|
|
205
207
|
export class DocumentLock {
|
|
206
|
-
constructor(documentId, keeper) {
|
|
208
|
+
constructor(documentId, keeper, lockId = null) {
|
|
207
209
|
this.documentId = documentId;
|
|
210
|
+
/** The holder id the acquire answered with. */
|
|
211
|
+
this.lockId = lockId;
|
|
208
212
|
this._keeper = keeper;
|
|
209
213
|
}
|
|
210
214
|
|
|
@@ -255,12 +259,13 @@ export async function withDocumentLock(
|
|
|
255
259
|
throw error;
|
|
256
260
|
}
|
|
257
261
|
|
|
262
|
+
const lockId = info?.lockId ?? null;
|
|
258
263
|
let keeper = null;
|
|
259
264
|
if (keepAlive) {
|
|
260
265
|
const ttlMs = lockTtlMs(info?.expiresAt, Date.now());
|
|
261
266
|
client.documentLockLost = null;
|
|
262
267
|
keeper = new LockKeeper(
|
|
263
|
-
(id) => client.documents.
|
|
268
|
+
(id) => client.documents.renewLock(id, lockId),
|
|
264
269
|
documentId,
|
|
265
270
|
ttlMs,
|
|
266
271
|
{
|
|
@@ -275,7 +280,7 @@ export async function withDocumentLock(
|
|
|
275
280
|
let result;
|
|
276
281
|
let threw = false;
|
|
277
282
|
try {
|
|
278
|
-
result = await fn(new DocumentLock(documentId, keeper));
|
|
283
|
+
result = await fn(new DocumentLock(documentId, keeper, lockId));
|
|
279
284
|
} catch (error) {
|
|
280
285
|
threw = true;
|
|
281
286
|
throw error;
|
|
@@ -286,7 +291,7 @@ export async function withDocumentLock(
|
|
|
286
291
|
// Best-effort release: the server TTL reclaims a stranded lock, and we must
|
|
287
292
|
// not let a release failure mask the real error from the body.
|
|
288
293
|
try {
|
|
289
|
-
await client.documents.releaseLock(documentId);
|
|
294
|
+
await client.documents.releaseLock(documentId, lockId);
|
|
290
295
|
} catch {
|
|
291
296
|
/* the lock expires on its own */
|
|
292
297
|
}
|
package/src/events.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { transformRequest } from "./transforms.js";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Client events: the opt-in research telemetry of a project (core manual,
|
|
5
|
+
// "Research telemetry").
|
|
6
|
+
//
|
|
7
|
+
// `client.events.record(type, fields)` is a fire-and-forget recorder. It
|
|
8
|
+
// buffers events and sends them every FLUSH_INTERVAL_MS or FLUSH_AT events,
|
|
9
|
+
// and when the page is hidden (`pagehide`, with `keepalive` so the request
|
|
10
|
+
// outlives the page). A batch that fails is dropped: no retry, no toast, no
|
|
11
|
+
// error for the caller. Nothing it does can block or break the screen that
|
|
12
|
+
// called it.
|
|
13
|
+
//
|
|
14
|
+
// It sends nothing for a project whose switch is off. The switch is read once
|
|
15
|
+
// from the project (`config.plaid.research.telemetry`) and kept for
|
|
16
|
+
// FLAG_TTL_MS; `events.setEnabled` tells it at once, which is what the
|
|
17
|
+
// settings checkbox does. A server refusal (the switch was turned off since)
|
|
18
|
+
// turns it off here too.
|
|
19
|
+
//
|
|
20
|
+
// ONE recorder per server per page, whatever client records into it: an app
|
|
21
|
+
// makes a client per editor, and a suggestion's "shown" is deduplicated for
|
|
22
|
+
// the page session, not per client. The last client to record lends its
|
|
23
|
+
// token to the next send.
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/** Every event type the server accepts. */
|
|
27
|
+
export const EVENT_TYPES = Object.freeze([
|
|
28
|
+
"suggestion.shown",
|
|
29
|
+
"suggestion.adopted",
|
|
30
|
+
"suggestion.dismissed",
|
|
31
|
+
"plan.opened",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
export const FLUSH_INTERVAL_MS = 10000;
|
|
35
|
+
export const FLUSH_AT = 50;
|
|
36
|
+
export const FLAG_TTL_MS = 5 * 60 * 1000;
|
|
37
|
+
// Events per request. A browser refuses a `keepalive` request whose body,
|
|
38
|
+
// with every other keepalive request still in flight, passes 64 KiB, and a
|
|
39
|
+
// refused request is lost. So the pagehide send goes out in small pieces, and
|
|
40
|
+
// only that send is keepalive: a backlog sent while the page stays (the first
|
|
41
|
+
// page of a grid, drawn while the switch was still being read, can hold
|
|
42
|
+
// hundreds of guesses) would otherwise lose everything past the quota. The
|
|
43
|
+
// server takes up to 500 in one request.
|
|
44
|
+
const PER_REQUEST = FLUSH_AT;
|
|
45
|
+
// A suggested or written value is a gloss, not prose. A long one is cut, in
|
|
46
|
+
// code points as the server counts them, so no character is split in two.
|
|
47
|
+
// The server refuses a value past this length, and a key its type does not
|
|
48
|
+
// take (core manual, "Research telemetry").
|
|
49
|
+
const MAX_VALUE_CHARS = 200;
|
|
50
|
+
|
|
51
|
+
const clip = (v) => {
|
|
52
|
+
if (typeof v !== "string" || v.length <= MAX_VALUE_CHARS) return v;
|
|
53
|
+
const cps = Array.from(v);
|
|
54
|
+
return cps.length > MAX_VALUE_CHARS
|
|
55
|
+
? cps.slice(0, MAX_VALUE_CHARS).join("")
|
|
56
|
+
: v;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export class EventRecorder {
|
|
60
|
+
/**
|
|
61
|
+
* @param {object} [opts]
|
|
62
|
+
* @param {function} [opts.now] - Clock, for tests
|
|
63
|
+
* @param {object} [opts.window] - Where `pagehide` fires (default globalThis)
|
|
64
|
+
*/
|
|
65
|
+
constructor({ now = () => Date.now(), window = globalThis } = {}) {
|
|
66
|
+
this.client = null;
|
|
67
|
+
this.buffer = [];
|
|
68
|
+
this.timer = null;
|
|
69
|
+
this.now = now;
|
|
70
|
+
// projectId -> { state: 'on' | 'off' | 'pending', at }
|
|
71
|
+
this.flags = new Map();
|
|
72
|
+
// "shown" keys already recorded this page session.
|
|
73
|
+
this.seen = new Set();
|
|
74
|
+
this._onPageHide = () => this.flush({ keepalive: true });
|
|
75
|
+
this.window = window;
|
|
76
|
+
window?.addEventListener?.("pagehide", this._onPageHide);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Tell the recorder a project's switch, e.g. right after changing it. */
|
|
80
|
+
setEnabled(projectId, on) {
|
|
81
|
+
this.flags.set(projectId, { state: on ? "on" : "off", at: this.now() });
|
|
82
|
+
if (!on) this._drop(projectId);
|
|
83
|
+
else if (this.buffer.length) this._schedule();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Record one event. Returns true when it was buffered, false when it was
|
|
88
|
+
* not (switch off, a repeat "shown", an unknown type, no project).
|
|
89
|
+
*/
|
|
90
|
+
record(client, type, { projectId, documentId, targetId, data } = {}) {
|
|
91
|
+
try {
|
|
92
|
+
if (!EVENT_TYPES.includes(type) || !projectId) return false;
|
|
93
|
+
this.client = client;
|
|
94
|
+
const flag = this._flag(projectId);
|
|
95
|
+
if (flag === "off") return false;
|
|
96
|
+
// A key with no value is left out: the server takes only a string or
|
|
97
|
+
// a number.
|
|
98
|
+
const clipped = data
|
|
99
|
+
? Object.fromEntries(
|
|
100
|
+
Object.entries(data)
|
|
101
|
+
.filter(([, v]) => v != null)
|
|
102
|
+
.map(([k, v]) => [k, clip(v)]),
|
|
103
|
+
)
|
|
104
|
+
: undefined;
|
|
105
|
+
let key = null;
|
|
106
|
+
if (type === "suggestion.shown") {
|
|
107
|
+
key = JSON.stringify([
|
|
108
|
+
projectId,
|
|
109
|
+
targetId ?? null,
|
|
110
|
+
clipped?.field ?? null,
|
|
111
|
+
clipped?.value ?? null,
|
|
112
|
+
]);
|
|
113
|
+
if (this.seen.has(key)) return false;
|
|
114
|
+
this.seen.add(key);
|
|
115
|
+
}
|
|
116
|
+
this.buffer.push({
|
|
117
|
+
projectId,
|
|
118
|
+
key,
|
|
119
|
+
event: {
|
|
120
|
+
type,
|
|
121
|
+
documentId: documentId ?? undefined,
|
|
122
|
+
targetId: targetId == null ? undefined : String(targetId),
|
|
123
|
+
data: clipped,
|
|
124
|
+
clientTs: new Date(this.now()).toISOString(),
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
if (this.buffer.length >= FLUSH_AT) this.flush();
|
|
128
|
+
else this._schedule();
|
|
129
|
+
return true;
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Send what is buffered for every project whose switch is known to be on,
|
|
137
|
+
* drop what is buffered for one known to be off, and keep the rest until
|
|
138
|
+
* the switch is known. With `keepalive` (the page is going away) nothing is
|
|
139
|
+
* kept.
|
|
140
|
+
*/
|
|
141
|
+
flush({ keepalive = false } = {}) {
|
|
142
|
+
clearTimeout(this.timer);
|
|
143
|
+
this.timer = null;
|
|
144
|
+
const keep = [];
|
|
145
|
+
const byProject = new Map();
|
|
146
|
+
for (const entry of this.buffer) {
|
|
147
|
+
const state = this.flags.get(entry.projectId)?.state;
|
|
148
|
+
if (state === "on") {
|
|
149
|
+
if (!byProject.has(entry.projectId)) byProject.set(entry.projectId, []);
|
|
150
|
+
byProject.get(entry.projectId).push(entry.event);
|
|
151
|
+
} else if (state === "off") {
|
|
152
|
+
if (entry.key) this.seen.delete(entry.key);
|
|
153
|
+
} else if (!keepalive) {
|
|
154
|
+
keep.push(entry);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
this.buffer = keep;
|
|
158
|
+
for (const [projectId, events] of byProject) {
|
|
159
|
+
for (let i = 0; i < events.length; i += PER_REQUEST) {
|
|
160
|
+
this._send(projectId, events.slice(i, i + PER_REQUEST), keepalive);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Stop listening for `pagehide` and forget everything (tests). */
|
|
166
|
+
close() {
|
|
167
|
+
clearTimeout(this.timer);
|
|
168
|
+
this.timer = null;
|
|
169
|
+
this.buffer = [];
|
|
170
|
+
this.window?.removeEventListener?.("pagehide", this._onPageHide);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
_schedule() {
|
|
174
|
+
if (this.timer == null) {
|
|
175
|
+
this.timer = setTimeout(() => this.flush(), FLUSH_INTERVAL_MS);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
_drop(projectId) {
|
|
180
|
+
this.buffer = this.buffer.filter((e) => {
|
|
181
|
+
if (e.projectId !== projectId) return true;
|
|
182
|
+
if (e.key) this.seen.delete(e.key);
|
|
183
|
+
return false;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The switch as known now: 'on', 'off' or 'pending'. An unknown one sets
|
|
188
|
+
// off a read of the project, and a stale one a fresh read while the old
|
|
189
|
+
// answer stands. The answer flushes or drops what is buffered.
|
|
190
|
+
_flag(projectId) {
|
|
191
|
+
const known = this.flags.get(projectId);
|
|
192
|
+
const fresh = known && this.now() - known.at < FLAG_TTL_MS;
|
|
193
|
+
if (known && (fresh || known.checking)) return known.state;
|
|
194
|
+
this.flags.set(projectId, {
|
|
195
|
+
state: known ? known.state : "pending",
|
|
196
|
+
at: known ? known.at : 0,
|
|
197
|
+
checking: true,
|
|
198
|
+
});
|
|
199
|
+
const client = this.client;
|
|
200
|
+
Promise.resolve()
|
|
201
|
+
.then(() => client.projects.get(projectId))
|
|
202
|
+
.then(
|
|
203
|
+
(project) => project?.config?.plaid?.research?.telemetry === true,
|
|
204
|
+
() => false,
|
|
205
|
+
)
|
|
206
|
+
.then((on) => {
|
|
207
|
+
this.flags.set(projectId, { state: on ? "on" : "off", at: this.now() });
|
|
208
|
+
if (!on) this._drop(projectId);
|
|
209
|
+
else if (this.buffer.length >= FLUSH_AT) this.flush();
|
|
210
|
+
else if (this.buffer.length) this._schedule();
|
|
211
|
+
});
|
|
212
|
+
return known ? known.state : "pending";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
_send(projectId, events, keepalive) {
|
|
216
|
+
const client = this.client;
|
|
217
|
+
try {
|
|
218
|
+
const url = `${client.baseUrl}/api/v1/projects/${projectId}/events`;
|
|
219
|
+
fetch(url, {
|
|
220
|
+
method: "POST",
|
|
221
|
+
keepalive,
|
|
222
|
+
headers: {
|
|
223
|
+
Authorization: `Bearer ${client.token}`,
|
|
224
|
+
"Content-Type": "application/json",
|
|
225
|
+
},
|
|
226
|
+
body: JSON.stringify(transformRequest(events)),
|
|
227
|
+
}).then(
|
|
228
|
+
(res) => {
|
|
229
|
+
// The switch was turned off since it was read.
|
|
230
|
+
if (res.status === 403) this.setEnabled(projectId, false);
|
|
231
|
+
},
|
|
232
|
+
() => {},
|
|
233
|
+
);
|
|
234
|
+
} catch {
|
|
235
|
+
/* dropped, as every failed batch is */
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const recorders = new Map();
|
|
241
|
+
|
|
242
|
+
/** The page's recorder for `client`'s server. */
|
|
243
|
+
export function recorderFor(client) {
|
|
244
|
+
let r = recorders.get(client.baseUrl);
|
|
245
|
+
if (!r) {
|
|
246
|
+
r = new EventRecorder();
|
|
247
|
+
recorders.set(client.baseUrl, r);
|
|
248
|
+
}
|
|
249
|
+
return r;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Forget every recorder (tests). */
|
|
253
|
+
export function resetEventRecorders() {
|
|
254
|
+
for (const r of recorders.values()) r.close();
|
|
255
|
+
recorders.clear();
|
|
256
|
+
}
|
package/src/http.js
CHANGED
|
@@ -64,6 +64,18 @@ export const DEFAULT_TIMEOUT_MS = 30000;
|
|
|
64
64
|
// nothing and costs a retry stacked on a write that is still running.
|
|
65
65
|
export const DEFAULT_BATCH_TIMEOUT_MS = 180000;
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Note how far the server's clock is from this machine's, from a response's
|
|
69
|
+
* Date header, for `client.serverNow()`. A time the server stamped (an audit
|
|
70
|
+
* entry's `ts`) has to be judged against the server's clock: a browser clock
|
|
71
|
+
* minutes off would misjudge it. A response with no readable Date header
|
|
72
|
+
* leaves the last offset as it was.
|
|
73
|
+
*/
|
|
74
|
+
export function noteServerClock(client, headers) {
|
|
75
|
+
const at = Date.parse(headers?.get?.("date") ?? "");
|
|
76
|
+
if (!Number.isNaN(at)) client.serverClockOffsetMs = at - Date.now();
|
|
77
|
+
}
|
|
78
|
+
|
|
67
79
|
/**
|
|
68
80
|
* Extract and update document versions from response headers and body.
|
|
69
81
|
*
|
|
@@ -412,6 +424,8 @@ export function prepareRequest(client, method, path, options = {}) {
|
|
|
412
424
|
url += `${separator}group-id=${encodeURIComponent(group.id)}`;
|
|
413
425
|
if (group.message)
|
|
414
426
|
url += `&group-message=${encodeURIComponent(group.message)}`;
|
|
427
|
+
if (group.kind) url += `&group-kind=${encodeURIComponent(group.kind)}`;
|
|
428
|
+
if (group.ref) url += `&group-ref=${encodeURIComponent(group.ref)}`;
|
|
415
429
|
group.written = true;
|
|
416
430
|
}
|
|
417
431
|
|
|
@@ -600,6 +614,7 @@ export async function makeRequest(client, method, path, options = {}) {
|
|
|
600
614
|
}
|
|
601
615
|
return res;
|
|
602
616
|
});
|
|
617
|
+
noteServerClock(client, response.headers);
|
|
603
618
|
|
|
604
619
|
if (!response.ok) {
|
|
605
620
|
const error = makeHttpError(
|
package/src/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
DEFAULT_BATCH_TIMEOUT_MS,
|
|
18
18
|
} from "./http.js";
|
|
19
19
|
import { listAll, listPage, iterPages } from "./pagination.js";
|
|
20
|
+
import { recorderFor } from "./events.js";
|
|
20
21
|
import { withDocumentLock } from "./documentLock.js";
|
|
21
22
|
import { createSSEConnection } from "./sse.js";
|
|
22
23
|
import {
|
|
@@ -206,6 +207,15 @@ async function sendChunks(client, url, ops, stamps, results) {
|
|
|
206
207
|
}
|
|
207
208
|
}
|
|
208
209
|
|
|
210
|
+
/** The query of an events read: `types` as the wire's comma-separated list. */
|
|
211
|
+
function eventQuery(types, startTime, endTime) {
|
|
212
|
+
return {
|
|
213
|
+
types: Array.isArray(types) ? types.join(",") || undefined : types,
|
|
214
|
+
"start-time": startTime,
|
|
215
|
+
"end-time": endTime,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
209
219
|
class PlaidClient {
|
|
210
220
|
/**
|
|
211
221
|
* Create a new PlaidClient instance
|
|
@@ -232,6 +242,9 @@ class PlaidClient {
|
|
|
232
242
|
? options.timeout
|
|
233
243
|
: DEFAULT_BATCH_TIMEOUT_MS;
|
|
234
244
|
this.documentVersions = {};
|
|
245
|
+
// The server's clock minus this machine's, in ms, from the last response
|
|
246
|
+
// with a Date header (null before one). See serverNow().
|
|
247
|
+
this.serverClockOffsetMs = null;
|
|
235
248
|
this.strictModeDocumentId = null;
|
|
236
249
|
// Set to a DocumentLockLost while a `documents.locked()` block's keep-alive
|
|
237
250
|
// has failed. Every write throws it until the block exits; see
|
|
@@ -251,6 +264,17 @@ class PlaidClient {
|
|
|
251
264
|
this._installResources();
|
|
252
265
|
}
|
|
253
266
|
|
|
267
|
+
/**
|
|
268
|
+
* The server's time now, as its last response's Date header put it (to the
|
|
269
|
+
* second), else this machine's. Judge a time the server stamped, such as an
|
|
270
|
+
* audit entry's `ts`, against this rather than `Date.now()`: a browser
|
|
271
|
+
* clock can be minutes off.
|
|
272
|
+
* @returns {Date}
|
|
273
|
+
*/
|
|
274
|
+
serverNow() {
|
|
275
|
+
return new Date(Date.now() + (this.serverClockOffsetMs ?? 0));
|
|
276
|
+
}
|
|
277
|
+
|
|
254
278
|
/**
|
|
255
279
|
* The API bundles (`client.documents`, `client.tokens`, ...). Installed on
|
|
256
280
|
* the client, and again on every batch it opens (see `batch()`): a batch is
|
|
@@ -1773,7 +1797,11 @@ class PlaidClient {
|
|
|
1773
1797
|
checkLock: (documentId) =>
|
|
1774
1798
|
this._request("GET", `/api/v1/documents/${documentId}/lock`),
|
|
1775
1799
|
/**
|
|
1776
|
-
* Acquire
|
|
1800
|
+
* Acquire a document lock as a new holder.
|
|
1801
|
+
*
|
|
1802
|
+
* The answer's `lockId` names the holder: `renewLock` and `releaseLock`
|
|
1803
|
+
* take it. While the lock is held, a second acquire is refused with
|
|
1804
|
+
* HTTP 423, whoever makes it, this user included.
|
|
1777
1805
|
*
|
|
1778
1806
|
* outOfBand: the lock is a signal, not project data (see the note at
|
|
1779
1807
|
* the top of http.js). Queued on a batch it would be taken only at
|
|
@@ -1788,13 +1816,30 @@ class PlaidClient {
|
|
|
1788
1816
|
outOfBand: true,
|
|
1789
1817
|
}),
|
|
1790
1818
|
/**
|
|
1791
|
-
*
|
|
1819
|
+
* Renew the lock `lockId` holds while it is live. HTTP 423 if another
|
|
1820
|
+
* holder has it, and also once it has expired or been dropped, even when
|
|
1821
|
+
* nobody holds the document now: a renewal never takes a free document.
|
|
1822
|
+
* outOfBand, for the same reason as acquireLock.
|
|
1823
|
+
* @param {string} documentId - The document ID
|
|
1824
|
+
* @param {string} lockId - The `lockId` acquireLock answered with
|
|
1825
|
+
*/
|
|
1826
|
+
renewLock: (documentId, lockId, auditMessage) =>
|
|
1827
|
+
this._request("POST", `/api/v1/documents/${documentId}/lock`, {
|
|
1828
|
+
queryParams: { "lock-id": lockId },
|
|
1829
|
+
auditMessage,
|
|
1830
|
+
outOfBand: true,
|
|
1831
|
+
}),
|
|
1832
|
+
/**
|
|
1833
|
+
* Release the lock `lockId` holds. Idempotent: a lock that holder no
|
|
1834
|
+
* longer has is left alone. outOfBand, for the same reason as
|
|
1792
1835
|
* acquireLock: queued, the lock would be held until the batch submits,
|
|
1793
1836
|
* and not released at all if it aborts.
|
|
1794
1837
|
* @param {string} documentId - The document ID
|
|
1838
|
+
* @param {string} lockId - The `lockId` acquireLock answered with
|
|
1795
1839
|
*/
|
|
1796
|
-
releaseLock: (documentId, auditMessage) =>
|
|
1840
|
+
releaseLock: (documentId, lockId, auditMessage) =>
|
|
1797
1841
|
this._request("DELETE", `/api/v1/documents/${documentId}/lock`, {
|
|
1842
|
+
queryParams: { "lock-id": lockId },
|
|
1798
1843
|
auditMessage,
|
|
1799
1844
|
outOfBand: true,
|
|
1800
1845
|
}),
|
|
@@ -1812,9 +1857,9 @@ class PlaidClient {
|
|
|
1812
1857
|
* tokenizer that deletes and recreates a document's tokens, spans and
|
|
1813
1858
|
* relations. A single atomic call does not need it. While the lock is
|
|
1814
1859
|
* held, writes to the document by ANOTHER user are refused with HTTP
|
|
1815
|
-
* 423; the holder's own writes pass and renew it. If
|
|
1816
|
-
*
|
|
1817
|
-
* run.
|
|
1860
|
+
* 423; the holder's own writes pass and renew it. If anyone already
|
|
1861
|
+
* holds it, another block of this same user included, this rejects with
|
|
1862
|
+
* a readable 423 and `fn` does not run.
|
|
1818
1863
|
*
|
|
1819
1864
|
* The lock is renewed for as long as `fn` runs, so work that computes
|
|
1820
1865
|
* for minutes before it writes holds the lock the whole time rather than
|
|
@@ -1824,11 +1869,13 @@ class PlaidClient {
|
|
|
1824
1869
|
* success. `fn` receives a lock handle and may read `lock.lost` (or call
|
|
1825
1870
|
* `lock.raiseIfLost()`) to give up sooner.
|
|
1826
1871
|
*
|
|
1827
|
-
*
|
|
1828
|
-
*
|
|
1829
|
-
*
|
|
1830
|
-
*
|
|
1831
|
-
*
|
|
1872
|
+
* The lock is per HOLDER: each block is its own, named by
|
|
1873
|
+
* `lock.lockId`, and only that id renews or releases it. NOT
|
|
1874
|
+
* re-entrant: a nested `locked()` block on one document is a second
|
|
1875
|
+
* holder and gets the 423. Lock at exactly one level per call path. A
|
|
1876
|
+
* lost lock is recorded on the CLIENT, like strict mode, so it stops
|
|
1877
|
+
* every write the client makes (on any batch of it too) and not only the
|
|
1878
|
+
* ones this block makes.
|
|
1832
1879
|
* @param {string} documentId - The document ID
|
|
1833
1880
|
* @param {(lock: DocumentLock) => any} fn - The work to run while holding it
|
|
1834
1881
|
* @param {object} [options] - `{ keepAlive }`: renew on a timer (default true)
|
|
@@ -2809,7 +2856,7 @@ class PlaidClient {
|
|
|
2809
2856
|
* @param {number} [timeout=10000] - Timeout in ms
|
|
2810
2857
|
* @param {function} [onProgress] - Called with each progress payload {percent, message}
|
|
2811
2858
|
* @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
|
|
2812
|
-
* @param {Object} [opts] - {requestId, onAccepted, projectIds}
|
|
2859
|
+
* @param {Object} [opts] - {requestId, onAccepted, projectIds, noOperation}
|
|
2813
2860
|
* @returns {Promise<any>} The service's result
|
|
2814
2861
|
*/
|
|
2815
2862
|
requestService: (
|
|
@@ -3125,6 +3172,100 @@ class PlaidClient {
|
|
|
3125
3172
|
),
|
|
3126
3173
|
};
|
|
3127
3174
|
|
|
3175
|
+
this.events = {
|
|
3176
|
+
/**
|
|
3177
|
+
* Record one research-telemetry event (core manual, "Research
|
|
3178
|
+
* telemetry"). Fire and forget: the event is buffered and sent with
|
|
3179
|
+
* others every ten seconds, at fifty events, and when the page is
|
|
3180
|
+
* hidden. A batch that fails is dropped, nothing is retried, and
|
|
3181
|
+
* nothing is ever thrown. Sends nothing for a project whose switch
|
|
3182
|
+
* (`config.plaid.research.telemetry`) is off, which it reads from the
|
|
3183
|
+
* project itself. `suggestion.shown` is recorded once per target,
|
|
3184
|
+
* field and value in a page session.
|
|
3185
|
+
* @param {'suggestion.shown'|'suggestion.adopted'|'suggestion.dismissed'|'plan.opened'} type - The event type
|
|
3186
|
+
* @param {object} fields
|
|
3187
|
+
* @param {string} fields.projectId - The project it happened in
|
|
3188
|
+
* @param {string} [fields.documentId] - The document it happened in
|
|
3189
|
+
* @param {string} [fields.targetId] - What it is about: a token, a plan
|
|
3190
|
+
* @param {object} [fields.data] - Its details, keyed by single lowercase words (`value`, `source`, `field`, `written`, `conversation`)
|
|
3191
|
+
* @returns {boolean} Whether the event was buffered
|
|
3192
|
+
*/
|
|
3193
|
+
record: (type, fields) => recorderFor(this).record(this, type, fields),
|
|
3194
|
+
/**
|
|
3195
|
+
* Send what the recorder holds now, rather than at its next interval.
|
|
3196
|
+
*/
|
|
3197
|
+
flush: () => recorderFor(this).flush(),
|
|
3198
|
+
/**
|
|
3199
|
+
* Tell the recorder a project's switch at once, e.g. right after a
|
|
3200
|
+
* maintainer changed it. Turning it off drops what is buffered.
|
|
3201
|
+
* @param {string} projectId - The project
|
|
3202
|
+
* @param {boolean} on - Whether telemetry is on
|
|
3203
|
+
*/
|
|
3204
|
+
setEnabled: (projectId, on) => recorderFor(this).setEnabled(projectId, on),
|
|
3205
|
+
/**
|
|
3206
|
+
* Record events directly, with no buffering. Requires write access, and
|
|
3207
|
+
* the server refuses with a 403 while the project's switch is off. All
|
|
3208
|
+
* or nothing: one bad event refuses the request with a 400 naming it.
|
|
3209
|
+
* @param {string} projectId - The project the events happened in
|
|
3210
|
+
* @param {Array<{type: string, documentId?: string, targetId?: string, data?: object, clientTs?: string}>} events - At most 500 events
|
|
3211
|
+
* @returns {Promise<{count: number}>}
|
|
3212
|
+
*/
|
|
3213
|
+
create: (projectId, events) =>
|
|
3214
|
+
this._request("POST", `/api/v1/projects/${projectId}/events`, {
|
|
3215
|
+
body: events,
|
|
3216
|
+
outOfBand: true,
|
|
3217
|
+
}),
|
|
3218
|
+
/**
|
|
3219
|
+
* A project's events in arrival order. Maintainer or admin only.
|
|
3220
|
+
* Transparently follows pagination cursors and returns the full flat
|
|
3221
|
+
* array.
|
|
3222
|
+
* @param {string} projectId - The project to read
|
|
3223
|
+
* @param {object} [filters]
|
|
3224
|
+
* @param {string|string[]} [filters.types] - Only these types
|
|
3225
|
+
* @param {string} [filters.startTime] - Only events the server stamped at or after this instant
|
|
3226
|
+
* @param {string} [filters.endTime] - Only events the server stamped at or before this instant
|
|
3227
|
+
*/
|
|
3228
|
+
list: (projectId, { types, startTime, endTime } = {}) =>
|
|
3229
|
+
listAll(this, `/api/v1/projects/${projectId}/events`, {
|
|
3230
|
+
query: eventQuery(types, startTime, endTime),
|
|
3231
|
+
}),
|
|
3232
|
+
/**
|
|
3233
|
+
* One page of a project's events. Maintainer or admin only.
|
|
3234
|
+
* @param {string} projectId - The project to read
|
|
3235
|
+
* @param {object} [opts]
|
|
3236
|
+
* @param {number} [opts.limit] - Page size (1..1000; server default 100)
|
|
3237
|
+
* @param {string} [opts.cursor] - Opaque cursor from a previous page
|
|
3238
|
+
* @param {string|string[]} [opts.types] - Only these types
|
|
3239
|
+
* @param {string} [opts.startTime] - Only events at or after this instant
|
|
3240
|
+
* @param {string} [opts.endTime] - Only events at or before this instant
|
|
3241
|
+
* @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
|
|
3242
|
+
*/
|
|
3243
|
+
listPage: (
|
|
3244
|
+
projectId,
|
|
3245
|
+
{ limit, cursor, types, startTime, endTime } = {},
|
|
3246
|
+
) =>
|
|
3247
|
+
listPage(this, `/api/v1/projects/${projectId}/events`, {
|
|
3248
|
+
limit,
|
|
3249
|
+
cursor,
|
|
3250
|
+
query: eventQuery(types, startTime, endTime),
|
|
3251
|
+
}),
|
|
3252
|
+
/**
|
|
3253
|
+
* Async-iterate a project's events page by page; yields each page's entries array.
|
|
3254
|
+
* @param {string} projectId - The project to read
|
|
3255
|
+
* @param {object} [opts]
|
|
3256
|
+
* @param {number} [opts.pageSize] - Per-request page size
|
|
3257
|
+
* @param {string|string[]} [opts.types] - Only these types
|
|
3258
|
+
* @param {string} [opts.startTime] - Only events at or after this instant
|
|
3259
|
+
* @param {string} [opts.endTime] - Only events at or before this instant
|
|
3260
|
+
* @returns {AsyncGenerator<Array>}
|
|
3261
|
+
*/
|
|
3262
|
+
iterPages: (projectId, { pageSize, types, startTime, endTime } = {}) =>
|
|
3263
|
+
iterPages(this, `/api/v1/projects/${projectId}/events`, {
|
|
3264
|
+
pageSize,
|
|
3265
|
+
query: eventQuery(types, startTime, endTime),
|
|
3266
|
+
}),
|
|
3267
|
+
};
|
|
3268
|
+
|
|
3128
3269
|
this.guidelines = {
|
|
3129
3270
|
/**
|
|
3130
3271
|
* Create a guideline in a project. `title` is the handle an assistant
|
|
@@ -3271,11 +3412,19 @@ class PlaidClient {
|
|
|
3271
3412
|
* control), and neither does a broadcast message (`messages.sendMessage`):
|
|
3272
3413
|
* none of them is audited, so there would be nothing under the label.
|
|
3273
3414
|
*
|
|
3415
|
+
* `kind` says what kind of operation this is, for a program reading the
|
|
3416
|
+
* log: one of `assistant-plan`, `service-run`, `import`, `bulk-edit`,
|
|
3417
|
+
* `guess-adoption` or `repair` (the server refuses any other). `ref` is a
|
|
3418
|
+
* short string naming what the operation came from, in the shape its kind
|
|
3419
|
+
* documents (the core manual, "Kinds of operation"). Both are recorded from
|
|
3420
|
+
* the first write like the label, and a nested operation keeps the outer
|
|
3421
|
+
* one's.
|
|
3422
|
+
*
|
|
3274
3423
|
* @param {string} message - Human label for the operation.
|
|
3275
|
-
* @param {object} [opts] - Optional `{ id }
|
|
3424
|
+
* @param {object} [opts] - Optional `{ id, kind, ref }`. `id` adopts an existing group id instead of minting one (a service joining the requester's operation; `requestService` propagates an open operation to the service automatically). `kind` and `ref` are described above.
|
|
3276
3425
|
* @returns {string} The operation's group id.
|
|
3277
3426
|
*/
|
|
3278
|
-
beginOperation(message, { id } = {}) {
|
|
3427
|
+
beginOperation(message, { id, kind, ref } = {}) {
|
|
3279
3428
|
if (this.operationGroup) {
|
|
3280
3429
|
this.operationGroup.depth += 1;
|
|
3281
3430
|
return this.operationGroup.id;
|
|
@@ -3283,6 +3432,8 @@ class PlaidClient {
|
|
|
3283
3432
|
this.operationGroup = {
|
|
3284
3433
|
id: id || crypto.randomUUID(),
|
|
3285
3434
|
message: message == null ? null : String(message),
|
|
3435
|
+
kind: kind == null ? null : String(kind),
|
|
3436
|
+
ref: ref == null ? null : String(ref),
|
|
3286
3437
|
depth: 1,
|
|
3287
3438
|
written: false,
|
|
3288
3439
|
refined: undefined,
|
|
@@ -3329,12 +3480,17 @@ class PlaidClient {
|
|
|
3329
3480
|
* setMessage(`Merged ${n} morphemes`);
|
|
3330
3481
|
* });
|
|
3331
3482
|
*
|
|
3483
|
+
* The kind and reference (see beginOperation) come after `fn`:
|
|
3484
|
+
*
|
|
3485
|
+
* await client.withOperation('Import ELAN corpus', run, { kind: 'import', ref: 'format:elan' });
|
|
3486
|
+
*
|
|
3332
3487
|
* @param {string} message - Human label for the operation.
|
|
3333
3488
|
* @param {function} fn - The work to run; receives `setMessage(msg)` to refine the label once the outcome is known.
|
|
3489
|
+
* @param {object} [opts] - Optional `{ kind, ref }`, as for beginOperation.
|
|
3334
3490
|
* @returns {Promise<any>} Whatever `fn` resolves to.
|
|
3335
3491
|
*/
|
|
3336
|
-
async withOperation(message, fn) {
|
|
3337
|
-
this.beginOperation(message);
|
|
3492
|
+
async withOperation(message, fn, { kind, ref } = {}) {
|
|
3493
|
+
this.beginOperation(message, { kind, ref });
|
|
3338
3494
|
const group = this.operationGroup;
|
|
3339
3495
|
const setMessage = (msg) => {
|
|
3340
3496
|
if (group.depth === 1) group.refined = msg;
|
|
@@ -3659,6 +3815,9 @@ export {
|
|
|
3659
3815
|
// request, and `critical()` holds that off around writes. See ./services.js
|
|
3660
3816
|
// and the manual, "Stopping a request".
|
|
3661
3817
|
export { ServiceCancelled, createCancelScope } from "./services.js";
|
|
3818
|
+
// Who asked for a service run, named in its History label and in what it
|
|
3819
|
+
// stores. See ./requester.js.
|
|
3820
|
+
export { REQUESTED_BY, makeRequester, requesterOf } from "./requester.js";
|
|
3662
3821
|
// Document locks: `client.documents.locked()` holds one for a block of work and
|
|
3663
3822
|
// renews it while the block runs. DocumentLockLost is what a write throws once
|
|
3664
3823
|
// a renewal has failed. See ./documentLock.js.
|
package/src/provenance.js
CHANGED
|
@@ -78,7 +78,11 @@
|
|
|
78
78
|
* - valueProbs / formProbs: a top-k {label: probability} map over the
|
|
79
79
|
* alternatives the producer considered, the chosen label included
|
|
80
80
|
* (provProb, when given, is that label's entry duplicated flat).
|
|
81
|
-
* - model
|
|
81
|
+
* - model: what made the prediction (a model name, or a tool and its
|
|
82
|
+
* release such as 'stanza==1.11.0'), and version: which version of the
|
|
83
|
+
* writer ran ('<release>+<8 hex>', see the manual, "Provenance"). Every
|
|
84
|
+
* machine writer records both, one of rules alone only version.
|
|
85
|
+
* - anything else (language, adapter, raw scores).
|
|
82
86
|
*/
|
|
83
87
|
|
|
84
88
|
/** The flat metadata keys of the provenance convention. */
|
package/src/requester.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Who asked for a service run, named in what the run leaves behind.
|
|
2
|
+
//
|
|
3
|
+
// A plain service writes with its own token, so the History entry of a run
|
|
4
|
+
// reads "by <operator> (via <token>)" and the person who pressed the button is
|
|
5
|
+
// recorded nowhere. Core tells every service who asked (`requesterId` in the
|
|
6
|
+
// request data), and every app's services put that person in the History
|
|
7
|
+
// label and in what they store:
|
|
8
|
+
//
|
|
9
|
+
// AnCast adjudication against lunch, requested by second
|
|
10
|
+
//
|
|
11
|
+
// This is the JS twin of `plaid_client.workflows.requester`, for a service
|
|
12
|
+
// authored with `serve`. The wording and the stored key are the same.
|
|
13
|
+
//
|
|
14
|
+
// The name is the requester's display name, which any signed-in user may
|
|
15
|
+
// read. When it cannot be read the id stands in for it, and with no requester
|
|
16
|
+
// at all the label is left as it was and nothing is stored.
|
|
17
|
+
|
|
18
|
+
/** The key a run's stored record names its requester under. */
|
|
19
|
+
export const REQUESTED_BY = "requestedBy";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The person who asked for one run: `id` is their account (their email),
|
|
23
|
+
* `name` how they are shown. Both are null when nobody asked.
|
|
24
|
+
*/
|
|
25
|
+
export function makeRequester(id = null, name = null) {
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
name,
|
|
29
|
+
/** A History label with the requester named, or `text` when nobody asked. */
|
|
30
|
+
label: (text) => (name ? `${text}, requested by ${name}` : text),
|
|
31
|
+
/** `{ id, name }` for a stored report, or null when nobody asked. */
|
|
32
|
+
record: () => (id ? { id, name } : null),
|
|
33
|
+
/** A machine stamp's `provDetail` with the requester's id added. */
|
|
34
|
+
detail: (detail = {}) => ({ ...(detail || {}), ...(id ? { [REQUESTED_BY]: id } : {}) }),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The requester of one request, from the `requesterId` core put in its data,
|
|
40
|
+
* with their display name read through `client`.
|
|
41
|
+
* @param {object} client - A PlaidClient (only `users.get` is used)
|
|
42
|
+
* @param {object} data - The request data a `serve` handler receives
|
|
43
|
+
*/
|
|
44
|
+
export async function requesterOf(client, data) {
|
|
45
|
+
const id = data && typeof data === "object" ? data.requesterId || null : null;
|
|
46
|
+
if (!id) return makeRequester();
|
|
47
|
+
let name = null;
|
|
48
|
+
try {
|
|
49
|
+
name = (await client.users.get(id))?.displayName || null;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// The id is always there to fall back on, so a failed read costs the
|
|
52
|
+
// label nothing but the nicer name.
|
|
53
|
+
console.warn(`Could not read the requester ${id}: ${error?.message || error}`);
|
|
54
|
+
}
|
|
55
|
+
return makeRequester(id, name || id);
|
|
56
|
+
}
|
package/src/services.js
CHANGED
|
@@ -402,6 +402,8 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
402
402
|
* @param {string[]} [opts.projectIds] - Other projects the request is about, beside
|
|
403
403
|
* `projectId`. A delegating service's token is scoped to `projectId` and to those of
|
|
404
404
|
* these the requester can read, and reaches nothing else
|
|
405
|
+
* @param {boolean} [opts.noOperation] - Carry no open operation: the service's writes
|
|
406
|
+
* are a group of their own, not part of whatever operation this client has open
|
|
405
407
|
* @returns {Promise<any>} The service's result
|
|
406
408
|
*/
|
|
407
409
|
export function requestService(client, projectId, serviceId, data, timeout = 10000, onProgress, signal, opts = {}) {
|
|
@@ -410,10 +412,26 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
|
|
|
410
412
|
// (the Python BaseService adopts the id around process_request). Only for a
|
|
411
413
|
// plain-object payload — that's the only shape the service param schema
|
|
412
414
|
// delivers anyway.
|
|
413
|
-
|
|
415
|
+
// Its kind and reference go too, so a service that writes before the
|
|
416
|
+
// requester does still records them.
|
|
417
|
+
//
|
|
418
|
+
// `opts.noOperation` sends none: the service starts a group of its own. For
|
|
419
|
+
// a request that is its own action whatever else is on the wire, such as
|
|
420
|
+
// approving an assistant's plan or starting a service run. The client holds
|
|
421
|
+
// one open operation, so without it an edit still saving at that moment
|
|
422
|
+
// would take in every write the request makes, under the edit's kind.
|
|
423
|
+
const group = opts.noOperation ? null : client.operationGroup;
|
|
414
424
|
const payload =
|
|
415
425
|
group && data && typeof data === 'object' && !Array.isArray(data)
|
|
416
|
-
? {
|
|
426
|
+
? {
|
|
427
|
+
...data,
|
|
428
|
+
operationGroup: {
|
|
429
|
+
id: group.id,
|
|
430
|
+
message: group.message,
|
|
431
|
+
...(group.kind ? { kind: group.kind } : {}),
|
|
432
|
+
...(group.ref ? { ref: group.ref } : {}),
|
|
433
|
+
},
|
|
434
|
+
}
|
|
417
435
|
: data;
|
|
418
436
|
const query = new URLSearchParams();
|
|
419
437
|
if (opts.requestId) query.set('request-id', opts.requestId);
|