@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 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
- releaseLock(documentId: string, auditMessage?: string): Promise<any>;
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 another user
915
- * holds it, and with DocumentLockLost if a renewal fails.
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
- operationGroup: { id: string; message: string | null } | null;
1359
- beginOperation(message: string, opts?: { id?: string }): string;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larc-iu/plaid-client",
3
- "version": "0.2.0-alpha.17",
3
+ "version": "0.2.0-alpha.18",
4
4
  "description": "JavaScript client for the Plaid annotation API",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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 a
14
- * plain acquire: the server refreshes a lock whose holder asks for it again.
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, so ours had
161
- // already expired. Anything else may be a blip, and is only fatal once
162
- // the lock we are renewing has actually run out.
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.acquireLock(id),
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 or refresh a document lock.
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
- * Release a document lock. outOfBand, for the same reason as
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 another user
1816
- * already holds it this rejects with a readable 423 and `fn` does not
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
- * NOT re-entrant: nesting two `locked()` blocks on one document would
1828
- * release on the inner exit and leave the outer unprotected. Lock at
1829
- * exactly one level per call path. A lost lock is recorded on the
1830
- * CLIENT, like strict mode, so it stops every write the client makes
1831
- * (on any batch of it too) and not only the ones this block makes.
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 }`: adopt an existing group id instead of minting one (a service joining the requester's operation; `requestService` propagates an open operation to the service automatically).
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, and anything else (language, adapter, raw scores).
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. */
@@ -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
- const group = client.operationGroup;
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
- ? { ...data, operationGroup: { id: group.id, message: group.message } }
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);