@larc-iu/plaid-client 0.2.0-alpha.11 → 0.2.0-alpha.12

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
@@ -25,6 +25,8 @@ interface ServiceParam {
25
25
  min?: number;
26
26
  max?: number;
27
27
  step?: number;
28
+ /** `number` only: render as a slider to drag rather than a box to type in. */
29
+ slider?: boolean;
28
30
  /** `string` only. */
29
31
  placeholder?: string;
30
32
  multiline?: boolean;
@@ -70,14 +72,41 @@ interface ServiceRegistration {
70
72
  }
71
73
 
72
74
  /** Connection-state transitions reported by a service registration. */
73
- type ServiceStatusEvent = 'registered' | 'reconnected' | 'disconnected';
75
+ type ServiceStatusEvent = "registered" | "reconnected" | "disconnected";
74
76
 
75
77
  interface ResponseHelper {
76
- progress(percent: number, message: string): void;
78
+ requestId: string;
79
+ requesterId: string | null;
80
+ /** True once the requester has asked this request to stop. */
81
+ readonly cancelled: boolean;
82
+ /**
83
+ * Report progress — and a cancellation CHECKPOINT: throws
84
+ * `ServiceCancelled` once a stop has been asked for, which is what makes a
85
+ * service that already reports progress cancellable for free.
86
+ */
87
+ progress(percent: number, message: string, extra?: Record<string, any>): void;
88
+ /** Stop here if the requester has asked the request to stop. */
89
+ raiseIfCancelled(): void;
90
+ /** Run `fn` as a stretch that must finish once begun, usually the writes. */
91
+ critical<T>(fn: () => T | Promise<T>): Promise<T>;
77
92
  complete(data: any): void;
93
+ /** End the request because it was asked to stop; result carries `stopped: true`. */
94
+ stopped(data?: any): void;
78
95
  error(error: string | Error): void;
79
96
  }
80
97
 
98
+ /** Thrown inside a handler when the requester has asked it to stop. */
99
+ export class ServiceCancelled extends Error {
100
+ constructor(message?: string);
101
+ }
102
+
103
+ /** The cancellation half of a responseHelper, on its own. */
104
+ export function createCancelScope(isCancelled: () => boolean): {
105
+ readonly cancelled: boolean;
106
+ raiseIfCancelled(): void;
107
+ critical<T>(fn: () => T | Promise<T>): Promise<T>;
108
+ };
109
+
81
110
  interface SSEConnection {
82
111
  close(): void;
83
112
  getStats(): any;
@@ -267,6 +296,7 @@ interface UsersBundle {
267
296
  endTime?: string,
268
297
  asOf?: string,
269
298
  ): Promise<any[]>;
299
+ auditPage(userId: string, opts?: AuditPageOptions): Promise<Page>;
270
300
  get(id: string, asOf?: string): Promise<any>;
271
301
  delete(id: string, auditMessage?: string): Promise<any>;
272
302
  activate(id: string, auditMessage?: string): Promise<any>;
@@ -313,12 +343,7 @@ interface CommentFilters {
313
343
 
314
344
  /** Entities that can carry a comment. */
315
345
  type CommentableType =
316
- | "document"
317
- | "text"
318
- | "token"
319
- | "span"
320
- | "relation"
321
- | "vocab-item";
346
+ "document" | "text" | "token" | "span" | "relation" | "vocab-item";
322
347
 
323
348
  interface Comment {
324
349
  id: string;
@@ -446,15 +471,182 @@ interface CreateInviteOptions {
446
471
  note?: string;
447
472
  }
448
473
 
474
+ /** Server limits, as reported by /api/v1/info. An unset limit is absent. */
475
+ interface ServerLimits {
476
+ mediaFileBytes?: number;
477
+ jsonBodyBytes?: number;
478
+ batchOperations?: number;
479
+ metadataDepth?: number;
480
+ metadataKeyCount?: number;
481
+ metadataStringLength?: number;
482
+ metadataTotalBytes?: number;
483
+ userDataValueBytes?: number;
484
+ }
485
+
486
+ interface ServerHealth {
487
+ ok: boolean;
488
+ version: string;
489
+ uptimeMs: number;
490
+ audit: { dbSizeMb?: number; auditRows?: number; error?: string };
491
+ }
492
+
493
+ interface DocumentLock {
494
+ documentId: string;
495
+ userId: string;
496
+ expiresAt: number;
497
+ }
498
+
499
+ interface RateLimitBucket {
500
+ ip: string;
501
+ userId?: string;
502
+ failures: number;
503
+ limit: number;
504
+ blocked: boolean;
505
+ }
506
+
507
+ interface RateLimitSnapshot {
508
+ windowMs: number;
509
+ logins: RateLimitBucket[];
510
+ ips: RateLimitBucket[];
511
+ invites: RateLimitBucket[];
512
+ }
513
+
514
+ interface BackupStatus {
515
+ enabled: boolean;
516
+ directory: string;
517
+ retention: number;
518
+ time: string;
519
+ backups: Array<{ name: string; bytes: number; modified: string }>;
520
+ ok?: boolean;
521
+ }
522
+
523
+ interface ServerReport {
524
+ version: string;
525
+ jvm: {
526
+ uptimeMs: number;
527
+ startedAt: string;
528
+ java: string;
529
+ heapUsed: number;
530
+ heapMax: number;
531
+ };
532
+ database: {
533
+ path: string | null;
534
+ bytes: number | null;
535
+ walBytes: number | null;
536
+ slowQueryThresholdMs?: number;
537
+ journalMode?: string;
538
+ maxPoolSize?: number;
539
+ tables: Record<string, number | null>;
540
+ };
541
+ media: {
542
+ directory: string;
543
+ files: number;
544
+ bytes: number;
545
+ orphans: number;
546
+ orphanBytes: number;
547
+ };
548
+ backup: BackupStatus;
549
+ settings: Record<string, any>;
550
+ }
551
+
552
+ /** Server-level facts. `info` is fetched at most once per client. */
553
+ interface ServerBundle {
554
+ info(): Promise<{ limits: ServerLimits }>;
555
+ limits(): Promise<ServerLimits>;
556
+ health(): Promise<ServerHealth>;
557
+ }
558
+
559
+ /** Instance-wide operations. Every method requires a global admin. */
560
+ interface AdminBundle {
561
+ server(): Promise<ServerReport>;
562
+ backup(): Promise<BackupStatus>;
563
+ locks(): Promise<{ entries: DocumentLock[] }>;
564
+ releaseLock(documentId: string): Promise<{ result: string }>;
565
+ rateLimits(): Promise<RateLimitSnapshot>;
566
+ clearRateLimits(opts?: {
567
+ ip?: string;
568
+ userId?: string;
569
+ }): Promise<{ result: string }>;
570
+ logs(opts?: {
571
+ lines?: number;
572
+ }): Promise<{ file: string | null; lines: string[]; error?: string }>;
573
+ userData(opts?: AdminUserDataOptions): Promise<AdminUserDataEntry[]>;
574
+ userDataPage(
575
+ opts?: AdminUserDataOptions & { limit?: number; cursor?: string },
576
+ ): Promise<Page<AdminUserDataEntry>>;
577
+ }
578
+
579
+ /** Narrowings for the cross-account private-data listing. */
580
+ interface AdminUserDataOptions {
581
+ /** Only keys starting with this literal head. */
582
+ prefix?: string;
583
+ /** Only keys matching this GLOB (`*` any run, `?` one character). */
584
+ pattern?: string;
585
+ includeValues?: boolean;
586
+ }
587
+
588
+ type AdminUserDataEntry = UserDataEntry & { userId: string };
589
+
590
+ /** Options for a single page of any audit log. */
591
+ interface AuditPageOptions {
592
+ startTime?: string;
593
+ endTime?: string;
594
+ asOf?: string;
595
+ opTypes?: string[] | string;
596
+ /** "desc" pages newest-first; a cursor belongs to the direction that made it. */
597
+ order?: "asc" | "desc";
598
+ limit?: number;
599
+ cursor?: string;
600
+ }
601
+
602
+ /** One user's activity over a scope and window. */
603
+ interface ActivityTallyRow {
604
+ user: { id: string; displayName?: string };
605
+ operations: number;
606
+ changes: number;
607
+ documents: number;
608
+ firstTs: string;
609
+ lastTs: string;
610
+ byDay?: Array<{ date: string; changes: number }>;
611
+ }
612
+
613
+ /** Instance-wide audit reads and the per-user aggregate. */
614
+ interface AuditBundle {
615
+ /** Admin only. */
616
+ list(opts?: {
617
+ startTime?: string;
618
+ endTime?: string;
619
+ opTypes?: string[] | string;
620
+ }): Promise<any[]>;
621
+ /** Admin only. */
622
+ listPage(opts?: AuditPageOptions): Promise<Page>;
623
+ /** Admin only. */
624
+ iterPages(opts?: {
625
+ startTime?: string;
626
+ endTime?: string;
627
+ opTypes?: string[] | string;
628
+ pageSize?: number;
629
+ }): AsyncGenerator<any[]>;
630
+ /** With projectId, open to that project's maintainers; without one, admin only. */
631
+ tally(opts?: {
632
+ projectId?: string;
633
+ startTime?: string;
634
+ endTime?: string;
635
+ daily?: boolean;
636
+ }): Promise<ActivityTallyRow[]>;
637
+ }
638
+
449
639
  interface InvitesBundle {
450
- list(opts?: { projectId?: string }): Promise<Invite[]>;
640
+ list(opts?: { projectId?: string; all?: boolean }): Promise<Invite[]>;
451
641
  listPage(opts?: {
452
642
  projectId?: string;
643
+ all?: boolean;
453
644
  limit?: number;
454
645
  cursor?: string;
455
646
  }): Promise<Page>;
456
647
  iterPages(opts?: {
457
648
  projectId?: string;
649
+ all?: boolean;
458
650
  pageSize?: number;
459
651
  }): AsyncGenerator<Invite[]>;
460
652
  /** The `code` is returned ONCE and is not recoverable afterward. */
@@ -532,6 +724,7 @@ interface DocumentsBundle {
532
724
  endTime?: string,
533
725
  asOf?: string,
534
726
  ): Promise<any[]>;
727
+ auditPage(documentId: string, opts?: AuditPageOptions): Promise<Page>;
535
728
  get(documentId: string, includeBody?: boolean, asOf?: string): Promise<any>;
536
729
  delete(documentId: string, auditMessage?: string): Promise<any>;
537
730
  update(documentId: string, name: string, auditMessage?: string): Promise<any>;
@@ -541,6 +734,19 @@ interface DocumentsBundle {
541
734
  metadata?: any,
542
735
  auditMessage?: string,
543
736
  ): Promise<any>;
737
+ /** Copy a document and everything in it into a new document of the same project. */
738
+ copy(
739
+ documentId: string,
740
+ name: string,
741
+ options?: { includeMedia?: boolean },
742
+ auditMessage?: string,
743
+ ): Promise<{ id: string; mediaError?: string }>;
744
+ restore(
745
+ documentId: string,
746
+ asOf: string,
747
+ options?: { dryRun?: boolean },
748
+ auditMessage?: string,
749
+ ): Promise<any>;
544
750
  }
545
751
 
546
752
  interface MessagesBundle {
@@ -567,7 +773,11 @@ interface MessagesBundle {
567
773
  serviceInfo: ServiceInfo,
568
774
  onServiceRequest: (data: any, responseHelper: ResponseHelper) => void,
569
775
  extras?: any,
570
- onStatus?: (event: ServiceStatusEvent, projectId: string, detail?: string) => void,
776
+ onStatus?: (
777
+ event: ServiceStatusEvent,
778
+ projectId: string,
779
+ detail?: string,
780
+ ) => void,
571
781
  ): ServiceRegistration;
572
782
  /**
573
783
  * Submit work to a service; streams progress to `onProgress`, resolves with
@@ -618,6 +828,7 @@ interface ProjectsBundle {
618
828
  endTime?: string,
619
829
  asOf?: string,
620
830
  ): Promise<any[]>;
831
+ auditPage(projectId: string, opts?: AuditPageOptions): Promise<Page>;
621
832
  linkVocab(id: string, vocabId: string, auditMessage?: string): Promise<any>;
622
833
  unlinkVocab(id: string, vocabId: string, auditMessage?: string): Promise<any>;
623
834
  get(id: string, asOf?: string): Promise<any>;
@@ -852,6 +1063,9 @@ export declare class PlaidClient {
852
1063
  apiTokens: ApiTokensBundle;
853
1064
  userData: UserDataBundle;
854
1065
  invites: InvitesBundle;
1066
+ server: ServerBundle;
1067
+ admin: AdminBundle;
1068
+ audit: AuditBundle;
855
1069
  comments: CommentsBundle;
856
1070
  tokenLayers: TokenLayersBundle;
857
1071
  documents: DocumentsBundle;
@@ -919,6 +1133,9 @@ export const TASKS: {
919
1133
  readonly TRANSCRIBE: "transcribe";
920
1134
  readonly LINK_VOCAB: "link-vocab";
921
1135
  readonly ANALYZE: "analyze";
1136
+ readonly TRANSLATE: "translate";
1137
+ readonly ASSIST: "assist";
1138
+ readonly DETECT_SPEECH: "detect-speech";
922
1139
  };
923
1140
  /** Whether a service serves a task (declared `extras.tasks`, legacy id-prefix fallback). */
924
1141
  export function servesTask(service: DiscoveredService, task: string): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larc-iu/plaid-client",
3
- "version": "0.2.0-alpha.11",
3
+ "version": "0.2.0-alpha.12",
4
4
  "description": "JavaScript client for the Plaid annotation API",
5
5
  "type": "module",
6
6
  "repository": {
package/src/index.js CHANGED
@@ -85,6 +85,31 @@ async function anonymousPost(baseUrl, path, body, options = {}) {
85
85
  }
86
86
  }
87
87
 
88
+ /**
89
+ * GET a path with no authentication, for the two endpoints a client may need
90
+ * before it has a session: /health and /api/v1/info.
91
+ */
92
+ async function anonymousGet(baseUrl, path, options = {}) {
93
+ const base = baseUrl.replace(/\/$/, "");
94
+ const url = `${base}${path}`;
95
+ try {
96
+ const fetchOptions = { method: "GET" };
97
+ const signal = timeoutSignal(
98
+ options.timeout !== undefined ? options.timeout : DEFAULT_TIMEOUT_MS,
99
+ );
100
+ if (signal) fetchOptions.signal = signal;
101
+
102
+ const response = await fetch(url, fetchOptions);
103
+ if (!response.ok) {
104
+ throw makeHttpError(response, await parseErrorBody(response), url, "GET");
105
+ }
106
+ return transformResponse(await response.json());
107
+ } catch (error) {
108
+ if (error.status) throw error;
109
+ throw makeNetworkError(error, url, "GET");
110
+ }
111
+ }
112
+
88
113
  /**
89
114
  * The server's cap on operations per batch request (plaid.rest-api.v1.batch).
90
115
  * A batch queued past it is submitted as consecutive requests.
@@ -633,6 +658,42 @@ class PlaidClient {
633
658
  }),
634
659
  };
635
660
 
661
+ // Server-level facts. Fetched at most once: the limits cannot change while
662
+ // the server is up, and a caller asking "will this file be accepted" should
663
+ // not pay a round trip to find out.
664
+ let infoPromise = null;
665
+ this.server = {
666
+ /**
667
+ * This server's version and the limits it enforces, e.g.
668
+ * `{ limits: { mediaFileBytes, jsonBodyBytes, batchOperations, … } }`.
669
+ * Sizes are in bytes. Unauthenticated.
670
+ */
671
+ info: () => {
672
+ if (!infoPromise) {
673
+ // bypassBatch: a read belongs to whoever asked for it, not to
674
+ // whatever batch happens to be open on this shared client.
675
+ infoPromise = this._request("GET", "/api/v1/info", {
676
+ bypassBatch: true,
677
+ }).catch((err) => {
678
+ // A failure must not be cached: a client that starts before the
679
+ // server is up would never see the limits at all.
680
+ infoPromise = null;
681
+ throw err;
682
+ });
683
+ }
684
+ return infoPromise;
685
+ },
686
+ /** Just the limits, which is what a caller almost always wants. */
687
+ limits: async () => (await this.server.info()).limits,
688
+ /**
689
+ * Liveness, version and database size. Unauthenticated, and served
690
+ * outside the REST router at /health, so it answers even while the API
691
+ * is refusing requests. Never cached — the point is that it is current.
692
+ * @returns {Promise<{ok: boolean, version: string, uptimeMs: number, audit: object}>}
693
+ */
694
+ health: () => PlaidClient.health(this.baseUrl, { timeout: this.timeout }),
695
+ };
696
+
636
697
  this.batch = {
637
698
  /**
638
699
  * Execute multiple API operations atomically. If any operation fails, all
@@ -812,6 +873,31 @@ class PlaidClient {
812
873
  "op-types": opTypesParam(opTypes),
813
874
  },
814
875
  }),
876
+ /**
877
+ * One page of the same log, newest-first with `order: "desc"`. Use this
878
+ * rather than audit() wherever the caller wants the recent end of a log
879
+ * that may be long: audit() walks every page before it resolves.
880
+ * @param {object} [opts]
881
+ * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
882
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
883
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
884
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
885
+ */
886
+ auditPage: (
887
+ userId,
888
+ { startTime, endTime, asOf, opTypes, order, limit, cursor } = {},
889
+ ) =>
890
+ listPage(this, `/api/v1/users/${userId}/audit`, {
891
+ limit,
892
+ cursor,
893
+ query: {
894
+ "start-time": startTime,
895
+ "end-time": endTime,
896
+ "as-of": asOf,
897
+ "op-types": opTypesParam(opTypes),
898
+ order,
899
+ },
900
+ }),
815
901
  /**
816
902
  * Get a user by ID
817
903
  * @param {string} id - The resource ID
@@ -1044,37 +1130,40 @@ class PlaidClient {
1044
1130
  * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1045
1131
  * @param {object} [opts]
1046
1132
  * @param {string} [opts.projectId] - List this project's invites instead of your own
1133
+ * @param {boolean} [opts.all] - List every invite on the server (admin only)
1047
1134
  */
1048
- list: ({ projectId } = {}) =>
1135
+ list: ({ projectId, all } = {}) =>
1049
1136
  listAll(this, "/api/v1/invites", {
1050
- query: { "project-id": projectId },
1137
+ query: { "project-id": projectId, all },
1051
1138
  }),
1052
1139
  /**
1053
1140
  * Fetch a single page of invites.
1054
1141
  * @param {object} [opts]
1055
1142
  * @param {string} [opts.projectId] - List this project's invites instead of your own
1143
+ * @param {boolean} [opts.all] - List every invite on the server (admin only)
1056
1144
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1057
1145
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
1058
1146
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1059
1147
  */
1060
- listPage: ({ projectId, limit, cursor } = {}) =>
1148
+ listPage: ({ projectId, all, limit, cursor } = {}) =>
1061
1149
  listPage(this, "/api/v1/invites", {
1062
1150
  limit,
1063
1151
  cursor,
1064
- query: { "project-id": projectId },
1152
+ query: { "project-id": projectId, all },
1065
1153
  }),
1066
1154
  /**
1067
1155
  * Async-iterate invites page by page; yields each page's entries array.
1068
1156
  * @param {object} [opts]
1069
1157
  * @param {string} [opts.projectId] - List this project's invites instead of your own
1158
+ * @param {boolean} [opts.all] - List every invite on the server (admin only)
1070
1159
  * @param {number} [opts.pageSize] - Per-request page size
1071
1160
  * Cannot be used inside a batch (auto-paginates across requests); throws on first iteration if called while batching — use listPage() for a single page in a batch.
1072
1161
  * @returns {AsyncGenerator<Array>}
1073
1162
  */
1074
- iterPages: ({ projectId, pageSize } = {}) =>
1163
+ iterPages: ({ projectId, all, pageSize } = {}) =>
1075
1164
  iterPages(this, "/api/v1/invites", {
1076
1165
  pageSize,
1077
- query: { "project-id": projectId },
1166
+ query: { "project-id": projectId, all },
1078
1167
  }),
1079
1168
  /**
1080
1169
  * Mint an invite. The returned `code` is shown ONLY here — it is never
@@ -1134,6 +1223,206 @@ class PlaidClient {
1134
1223
  this._request("DELETE", `/api/v1/invites/${id}`, { auditMessage }),
1135
1224
  };
1136
1225
 
1226
+ this.admin = {
1227
+ /**
1228
+ * Everything about the server in one read: version and JVM uptime,
1229
+ * database size and per-table row counts, media directory usage, backup
1230
+ * configuration and the backups on disk, and the settings an operator
1231
+ * gets asked to confirm. Carries no secrets. Runs a count per table and
1232
+ * walks the media directory, so open it, do not poll it. Admin only.
1233
+ * @returns {Promise<{version: string, jvm: object, database: object, media: object, backup: object, settings: object}>}
1234
+ */
1235
+ server: () => this._request("GET", "/api/v1/admin/server"),
1236
+ /**
1237
+ * Take a database backup right now, outside the nightly schedule.
1238
+ * Resolves to the backup block with `ok` reporting whether the snapshot
1239
+ * succeeded. Uses VACUUM INTO, which only reads, so it is safe while
1240
+ * people are working. Admin only.
1241
+ * @returns {Promise<{ok: boolean, directory: string, backups: Array}>}
1242
+ */
1243
+ backup: () => this._request("POST", "/api/v1/admin/backup"),
1244
+ /**
1245
+ * Documents currently held by an editing lock, with who holds each and
1246
+ * when it expires on its own. Admin only.
1247
+ * @returns {Promise<{entries: Array<{documentId: string, userId: string, expiresAt: number}>}>}
1248
+ */
1249
+ locks: () => this._request("GET", "/api/v1/admin/locks"),
1250
+ /**
1251
+ * Drop the lock on a document whoever holds it. Idempotent. For a client
1252
+ * that went away without releasing one. Admin only.
1253
+ * @param {string} documentId - The document ID
1254
+ */
1255
+ releaseLock: (documentId) =>
1256
+ this._request("DELETE", `/api/v1/admin/locks/${documentId}`),
1257
+ /**
1258
+ * Live login and invite rate-limit buckets: the address, the account
1259
+ * where there is one, failures inside the window, the limit, and whether
1260
+ * it is currently blocking. Admin only.
1261
+ * @returns {Promise<{windowMs: number, logins: Array, ips: Array, invites: Array}>}
1262
+ */
1263
+ rateLimits: () => this._request("GET", "/api/v1/admin/rate-limits"),
1264
+ /**
1265
+ * Forget recorded rate-limit failures. With `ip`, clears that address,
1266
+ * narrowed to one account with `userId`. With neither, clears every
1267
+ * bucket. Only ever unblocks. Admin only.
1268
+ * @param {object} [opts]
1269
+ * @param {string} [opts.ip] - Clear only this address
1270
+ * @param {string} [opts.userId] - Narrow to one account on that address
1271
+ */
1272
+ clearRateLimits: ({ ip, userId } = {}) =>
1273
+ this._request("DELETE", "/api/v1/admin/rate-limits", {
1274
+ queryParams: { ip, "user-id": userId },
1275
+ }),
1276
+ /**
1277
+ * The tail of the configured log file. Resolves with an `error` string
1278
+ * instead of lines when no log file is configured or it does not exist
1279
+ * yet. Admin only.
1280
+ * @param {object} [opts]
1281
+ * @param {number} [opts.lines] - How many lines (default 200, max 2000)
1282
+ * @returns {Promise<{file: (string|null), lines: string[], error?: string}>}
1283
+ */
1284
+ logs: ({ lines } = {}) =>
1285
+ this._request("GET", "/api/v1/admin/logs", {
1286
+ queryParams: { lines },
1287
+ }),
1288
+ /**
1289
+ * Private user-data entries across every account, each carrying its
1290
+ * `userId`. `/users/:userId/data` has always been owner-or-admin; this
1291
+ * is the same reach across accounts at once. Transparently follows
1292
+ * pagination cursors and returns the full flat array. Admin only.
1293
+ *
1294
+ * Narrow with `prefix` (the literal head of a key) and/or `pattern`, a
1295
+ * GLOB over the whole key (`*` any run, `?` one character) — the way to
1296
+ * ask for a key convention identified by a segment in the middle, e.g.
1297
+ * `igt:assistant:*:meta:*`. Values come only with `includeValues`, and
1298
+ * are recased like any other body (see `userData.put`).
1299
+ * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use userDataPage() for a single page in a batch.
1300
+ * @param {object} [opts]
1301
+ * @param {string} [opts.prefix] - Only keys starting with this
1302
+ * @param {string} [opts.pattern] - Only keys matching this GLOB
1303
+ * @param {boolean} [opts.includeValues] - Also return each entry's value
1304
+ * @returns {Promise<Array<{userId: string, key: string, updatedAt: string, value?: *}>>}
1305
+ */
1306
+ userData: ({ prefix, pattern, includeValues } = {}) =>
1307
+ listAll(this, "/api/v1/admin/user-data", {
1308
+ query: {
1309
+ prefix,
1310
+ pattern,
1311
+ "include-values": includeValues,
1312
+ },
1313
+ }),
1314
+ /**
1315
+ * One page of private user-data entries across accounts, ordered by
1316
+ * (user, key). Admin only.
1317
+ * @param {object} [opts]
1318
+ * @param {string} [opts.prefix] - Only keys starting with this
1319
+ * @param {string} [opts.pattern] - Only keys matching this GLOB
1320
+ * @param {boolean} [opts.includeValues] - Also return each entry's value
1321
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1322
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
1323
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1324
+ */
1325
+ userDataPage: ({ prefix, pattern, includeValues, limit, cursor } = {}) =>
1326
+ listPage(this, "/api/v1/admin/user-data", {
1327
+ limit,
1328
+ cursor,
1329
+ query: {
1330
+ prefix,
1331
+ pattern,
1332
+ "include-values": includeValues,
1333
+ },
1334
+ }),
1335
+ };
1336
+
1337
+ this.audit = {
1338
+ /**
1339
+ * The audit log across every project, oldest first. Same fold, window
1340
+ * and op-type filter as the per-project read, with the entity scope
1341
+ * dropped. Admin only. Transparently follows pagination cursors and
1342
+ * returns the full flat array.
1343
+ * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
1344
+ * @param {object} [opts]
1345
+ * @param {string} [opts.startTime] - Only operations at or after this instant
1346
+ * @param {string} [opts.endTime] - Only operations at or before this instant
1347
+ * @param {string[]|string} [opts.opTypes] - Only these op types (e.g.
1348
+ * `['span-layer/create']`). An entry appears when one of its operations
1349
+ * matches, carrying only the ones that did.
1350
+ */
1351
+ list: ({ startTime, endTime, opTypes } = {}) =>
1352
+ listAll(this, "/api/v1/audit", {
1353
+ query: {
1354
+ "start-time": startTime,
1355
+ "end-time": endTime,
1356
+ "op-types": opTypesParam(opTypes),
1357
+ },
1358
+ }),
1359
+ /**
1360
+ * Fetch a single page of the instance-wide audit log. Admin only.
1361
+ * @param {object} [opts]
1362
+ * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first, which
1363
+ * is what a feed wants. A cursor belongs to the direction that made it.
1364
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1365
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
1366
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1367
+ */
1368
+ listPage: ({ startTime, endTime, opTypes, order, limit, cursor } = {}) =>
1369
+ listPage(this, "/api/v1/audit", {
1370
+ limit,
1371
+ cursor,
1372
+ query: {
1373
+ "start-time": startTime,
1374
+ "end-time": endTime,
1375
+ "op-types": opTypesParam(opTypes),
1376
+ order,
1377
+ },
1378
+ }),
1379
+ /**
1380
+ * Async-iterate the instance-wide audit log page by page. Admin only.
1381
+ * Cannot be used inside a batch (auto-paginates across requests); throws on first iteration if called while batching — use listPage() for a single page in a batch.
1382
+ * @returns {AsyncGenerator<Array>}
1383
+ */
1384
+ iterPages: ({ startTime, endTime, opTypes, pageSize } = {}) =>
1385
+ iterPages(this, "/api/v1/audit", {
1386
+ pageSize,
1387
+ query: {
1388
+ "start-time": startTime,
1389
+ "end-time": endTime,
1390
+ "op-types": opTypesParam(opTypes),
1391
+ },
1392
+ }),
1393
+ /**
1394
+ * Per-user activity counts. `changes` is the number of logical actions,
1395
+ * folded the way the audit feed folds them, so one "Confirm word
1396
+ * analysis" counts once however many rows it wrote; `operations` is the
1397
+ * unfolded row count. Only users who did something appear — subtract
1398
+ * from the roster you already hold to find the ones who did not.
1399
+ *
1400
+ * With `projectId`, scoped to that project and open to its maintainers.
1401
+ * Without one, instance-wide and admin only.
1402
+ * @param {object} [opts]
1403
+ * @param {string} [opts.projectId] - Scope to one project
1404
+ * @param {string} [opts.startTime] - Only count at or after this instant
1405
+ * @param {string} [opts.endTime] - Only count at or before this instant
1406
+ * @param {boolean} [opts.daily] - Also return `byDay`, a list of
1407
+ * `{date, changes}` per user, oldest first, at the cost of a second
1408
+ * grouped scan
1409
+ * @returns {Promise<Array<{user: object, operations: number, changes: number, documents: number, firstTs: string, lastTs: string, byDay?: object}>>}
1410
+ */
1411
+ tally: async ({ projectId, startTime, endTime, daily } = {}) => {
1412
+ const path = projectId
1413
+ ? `/api/v1/projects/${projectId}/audit/tally`
1414
+ : "/api/v1/audit/tally";
1415
+ const result = await this._request("GET", path, {
1416
+ queryParams: {
1417
+ "start-time": startTime,
1418
+ "end-time": endTime,
1419
+ daily,
1420
+ },
1421
+ });
1422
+ return result.entries;
1423
+ },
1424
+ };
1425
+
1137
1426
  this.tokenLayers = {
1138
1427
  /**
1139
1428
  * Shift a token layer's display order.
@@ -1345,6 +1634,31 @@ class PlaidClient {
1345
1634
  "op-types": opTypesParam(opTypes),
1346
1635
  },
1347
1636
  }),
1637
+ /**
1638
+ * One page of the same log, newest-first with `order: "desc"`. Use this
1639
+ * rather than audit() wherever the caller wants the recent end of a log
1640
+ * that may be long: audit() walks every page before it resolves.
1641
+ * @param {object} [opts]
1642
+ * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
1643
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1644
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
1645
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1646
+ */
1647
+ auditPage: (
1648
+ documentId,
1649
+ { startTime, endTime, asOf, opTypes, order, limit, cursor } = {},
1650
+ ) =>
1651
+ listPage(this, `/api/v1/documents/${documentId}/audit`, {
1652
+ limit,
1653
+ cursor,
1654
+ query: {
1655
+ "start-time": startTime,
1656
+ "end-time": endTime,
1657
+ "as-of": asOf,
1658
+ "op-types": opTypesParam(opTypes),
1659
+ order,
1660
+ },
1661
+ }),
1348
1662
  /**
1349
1663
  * Restore a document to its state at an earlier time, as one
1350
1664
  * operation: what was deleted since then comes back under its original
@@ -1361,7 +1675,10 @@ class PlaidClient {
1361
1675
  */
1362
1676
  restore: (documentId, asOf, { dryRun } = {}, auditMessage) =>
1363
1677
  this._request("POST", `/api/v1/documents/${documentId}/restore`, {
1364
- queryParams: { "as-of": asOf, "dry-run": dryRun ? "true" : undefined },
1678
+ queryParams: {
1679
+ "as-of": asOf,
1680
+ "dry-run": dryRun ? "true" : undefined,
1681
+ },
1365
1682
  auditMessage,
1366
1683
  }),
1367
1684
  /**
@@ -1416,6 +1733,25 @@ class PlaidClient {
1416
1733
  auditMessage,
1417
1734
  body: bodyOf({ "project-id": projectId, name, metadata }),
1418
1735
  }),
1736
+ /**
1737
+ * Copy a document and everything in it into a new document of the same
1738
+ * project, as one operation. The copy shares the source's layers and
1739
+ * the vocabulary entries its links name, and holds the source's texts,
1740
+ * tokens, spans, relations and vocab links under fresh ids, with their
1741
+ * metadata. Comments do not travel. The media file does, unless
1742
+ * `includeMedia` is false. Resolves to `{ id }`, plus `mediaError` when
1743
+ * the source had media the copy could not take with it.
1744
+ * @param {string} documentId - The document to copy
1745
+ * @param {string} name - The new document's name
1746
+ * @param {object} [options] - `{ includeMedia }`: false leaves the media
1747
+ * file behind
1748
+ * @param {string} [auditMessage] - Custom audit message for this operation
1749
+ */
1750
+ copy: (documentId, name, { includeMedia } = {}, auditMessage) =>
1751
+ this._request("POST", `/api/v1/documents/${documentId}/copy`, {
1752
+ auditMessage,
1753
+ body: bodyOf({ name, "include-media": includeMedia }),
1754
+ }),
1419
1755
  };
1420
1756
 
1421
1757
  this.projects = {
@@ -1522,6 +1858,31 @@ class PlaidClient {
1522
1858
  "op-types": opTypesParam(opTypes),
1523
1859
  },
1524
1860
  }),
1861
+ /**
1862
+ * One page of the same log, newest-first with `order: "desc"`. Use this
1863
+ * rather than audit() wherever the caller wants the recent end of a log
1864
+ * that may be long: audit() walks every page before it resolves.
1865
+ * @param {object} [opts]
1866
+ * @param {"asc"|"desc"} [opts.order] - "desc" pages newest-first
1867
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
1868
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
1869
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
1870
+ */
1871
+ auditPage: (
1872
+ projectId,
1873
+ { startTime, endTime, asOf, opTypes, order, limit, cursor } = {},
1874
+ ) =>
1875
+ listPage(this, `/api/v1/projects/${projectId}/audit`, {
1876
+ limit,
1877
+ cursor,
1878
+ query: {
1879
+ "start-time": startTime,
1880
+ "end-time": endTime,
1881
+ "as-of": asOf,
1882
+ "op-types": opTypesParam(opTypes),
1883
+ order,
1884
+ },
1885
+ }),
1525
1886
  /**
1526
1887
  * When the calling user last wrote to each document in a project, as a
1527
1888
  * `{documentId: timestamp}` map. Documents they have never written to
@@ -2163,8 +2524,25 @@ class PlaidClient {
2163
2524
  * @param {Object} [opts] - {requestId, onAccepted}
2164
2525
  * @returns {Promise<any>} The service's result
2165
2526
  */
2166
- requestService: (projectId, serviceId, data, timeout, onProgress, signal, opts) =>
2167
- requestService(this, projectId, serviceId, data, timeout, onProgress, signal, opts),
2527
+ requestService: (
2528
+ projectId,
2529
+ serviceId,
2530
+ data,
2531
+ timeout,
2532
+ onProgress,
2533
+ signal,
2534
+ opts,
2535
+ ) =>
2536
+ requestService(
2537
+ this,
2538
+ projectId,
2539
+ serviceId,
2540
+ data,
2541
+ timeout,
2542
+ onProgress,
2543
+ signal,
2544
+ opts,
2545
+ ),
2168
2546
 
2169
2547
  /**
2170
2548
  * Rejoin a service request made earlier and await its result: the
@@ -2180,8 +2558,21 @@ class PlaidClient {
2180
2558
  * @param {AbortSignal} [signal] - Abort to stop waiting
2181
2559
  * @returns {Promise<any>} The service's result
2182
2560
  */
2183
- attachServiceRequest: (projectId, requestId, timeout, onProgress, signal) =>
2184
- attachServiceRequest(this, projectId, requestId, timeout, onProgress, signal),
2561
+ attachServiceRequest: (
2562
+ projectId,
2563
+ requestId,
2564
+ timeout,
2565
+ onProgress,
2566
+ signal,
2567
+ ) =>
2568
+ attachServiceRequest(
2569
+ this,
2570
+ projectId,
2571
+ requestId,
2572
+ timeout,
2573
+ onProgress,
2574
+ signal,
2575
+ ),
2185
2576
 
2186
2577
  /**
2187
2578
  * Ask the service to stop a request made earlier. The request still
@@ -2238,7 +2629,11 @@ class PlaidClient {
2238
2629
  // and the server ran the query against the batch's tx Connection, which
2239
2630
  // 500s and rolls back every write in that batch.
2240
2631
  this.query = (body, auditMessage) =>
2241
- this._request("POST", "/api/v1/query", { auditMessage, body, bypassBatch: true });
2632
+ this._request("POST", "/api/v1/query", {
2633
+ auditMessage,
2634
+ body,
2635
+ bypassBatch: true,
2636
+ });
2242
2637
 
2243
2638
  // Logical-operation groups (audit-log grouping). There is no create: a
2244
2639
  // group row is made lazily by the first write carrying `?group-id=`
@@ -2333,7 +2728,10 @@ class PlaidClient {
2333
2728
  * @param {string} [opts.entityId] - With entityType, one entity's thread
2334
2729
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
2335
2730
  */
2336
- listPage: (projectId, { limit, cursor, documentId, entityType, entityId } = {}) =>
2731
+ listPage: (
2732
+ projectId,
2733
+ { limit, cursor, documentId, entityType, entityId } = {},
2734
+ ) =>
2337
2735
  listPage(this, `/api/v1/projects/${projectId}/comments`, {
2338
2736
  limit,
2339
2737
  cursor,
@@ -2354,7 +2752,10 @@ class PlaidClient {
2354
2752
  * @param {string} [opts.entityId] - With entityType, one entity's thread
2355
2753
  * @returns {AsyncGenerator<Array>}
2356
2754
  */
2357
- iterPages: (projectId, { pageSize, documentId, entityType, entityId } = {}) =>
2755
+ iterPages: (
2756
+ projectId,
2757
+ { pageSize, documentId, entityType, entityId } = {},
2758
+ ) =>
2358
2759
  iterPages(this, `/api/v1/projects/${projectId}/comments`, {
2359
2760
  pageSize,
2360
2761
  query: {
@@ -2421,10 +2822,14 @@ class PlaidClient {
2421
2822
  * @returns {Promise<Object<string, number>>}
2422
2823
  */
2423
2824
  countsInVocab: (vocabId, { entityId } = {}) =>
2424
- this._request("GET", `/api/v1/vocab-layers/${vocabId}/comments/counts`, {
2425
- queryParams: { "entity-id": entityId },
2426
- skipResponseTransform: true,
2427
- }),
2825
+ this._request(
2826
+ "GET",
2827
+ `/api/v1/vocab-layers/${vocabId}/comments/counts`,
2828
+ {
2829
+ queryParams: { "entity-id": entityId },
2830
+ skipResponseTransform: true,
2831
+ },
2832
+ ),
2428
2833
  };
2429
2834
  }
2430
2835
 
@@ -2580,7 +2985,9 @@ class PlaidClient {
2580
2985
  // must not fail on its size alone.
2581
2986
  const results = [];
2582
2987
  for (let i = 0; i < ops.length; i += MAX_BATCH_OPS) {
2583
- results.push(...(await this._postBatch(url, ops.slice(i, i + MAX_BATCH_OPS))));
2988
+ results.push(
2989
+ ...(await this._postBatch(url, ops.slice(i, i + MAX_BATCH_OPS))),
2990
+ );
2584
2991
  }
2585
2992
  return results;
2586
2993
  } finally {
@@ -2703,6 +3110,27 @@ class PlaidClient {
2703
3110
  * @param {object} [options] - Client options forwarded to the constructor (e.g. { timeout })
2704
3111
  * @returns {Promise<PlaidClient>} - Authenticated client instance
2705
3112
  */
3113
+ /**
3114
+ * Liveness, version and database size, with NO authentication and no client
3115
+ * instance — for a launcher or status page checking whether a server is up
3116
+ * before anyone logs in.
3117
+ * @param {string} baseUrl - The API base URL
3118
+ * @param {object} [options]
3119
+ */
3120
+ static async health(baseUrl, options = {}) {
3121
+ return anonymousGet(baseUrl, "/health", options);
3122
+ }
3123
+
3124
+ /**
3125
+ * The limits this server enforces, with NO authentication and no client
3126
+ * instance. See the instance method for the shape.
3127
+ * @param {string} baseUrl - The API base URL
3128
+ * @param {object} [options]
3129
+ */
3130
+ static async info(baseUrl, options = {}) {
3131
+ return anonymousGet(baseUrl, "/api/v1/info", options);
3132
+ }
3133
+
2706
3134
  /**
2707
3135
  * Build the link to hand someone for an invite code. The server never sees
2708
3136
  * an app URL, so the app that minted the invite is the one that names it.
@@ -2730,12 +3158,7 @@ class PlaidClient {
2730
3158
  * @returns {Promise<{kind: string, status: string, expiresAt: string, projectName?: string, projectRole?: string, email?: string, grantAdmin: boolean}>}
2731
3159
  */
2732
3160
  static async lookupInvite(baseUrl, code, options = {}) {
2733
- return anonymousPost(
2734
- baseUrl,
2735
- "/api/v1/invite-codes/lookup",
2736
- { code },
2737
- options,
2738
- );
3161
+ return anonymousPost(baseUrl, "/api/v1/invites/lookup", { code }, options);
2739
3162
  }
2740
3163
 
2741
3164
  /**
@@ -2766,7 +3189,7 @@ class PlaidClient {
2766
3189
  ) {
2767
3190
  const data = await anonymousPost(
2768
3191
  baseUrl,
2769
- "/api/v1/invite-codes/redeem",
3192
+ "/api/v1/invites/redeem",
2770
3193
  bodyOf({ code, email, password, "display-name": displayName }),
2771
3194
  options,
2772
3195
  );
@@ -2831,6 +3254,7 @@ export {
2831
3254
  export {
2832
3255
  PLAID_NAMESPACE,
2833
3256
  ROLE_KEY,
3257
+ PRESERVE_ON_SPLIT_KEY,
2834
3258
  ROLES,
2835
3259
  readRole,
2836
3260
  findByRole,
@@ -2847,6 +3271,11 @@ export {
2847
3271
  buildDefaultValues,
2848
3272
  coerceParamValues,
2849
3273
  } from "./serviceSchema.js";
3274
+ // Cooperative cancellation for a service AUTHORED in JS: `progress()` is a
3275
+ // checkpoint that throws ServiceCancelled once the requester stops the
3276
+ // request, and `critical()` holds that off around writes. See ./services.js
3277
+ // and the manual, "Stopping a request".
3278
+ export { ServiceCancelled, createCancelScope } from "./services.js";
2850
3279
  // Provenance: the cross-app convention for who made an annotation (flat
2851
3280
  // prov/provSource/provConfirmed metadata; absence = a verifier; 'inferred' =
2852
3281
  // a machine; 'contributed' = a person whose work is reviewed), plus the
@@ -2855,6 +3284,7 @@ export {
2855
3284
  PROV,
2856
3285
  PROV_STATES,
2857
3286
  PROV_CONFIRMED,
3287
+ PROVENANCE_KEYS,
2858
3288
  stampInferred,
2859
3289
  confirmedInferred,
2860
3290
  stampContributed,
package/src/provenance.js CHANGED
@@ -99,6 +99,25 @@ export const PROV = Object.freeze({
99
99
  CONTRIBUTED: 'contributed',
100
100
  });
101
101
 
102
+ /**
103
+ * Every provenance key, for a caller that has to name them as a set rather
104
+ * than read one: what a token layer declares under
105
+ * `config.plaid.preserveOnSplit` so a token born of a split inherits the
106
+ * origin of the token it came from, and what a reshape carries by hand.
107
+ *
108
+ * Provenance is the one metadata family Plaid itself owns. The user cannot
109
+ * maintain it, and in a shared project usually cannot even see it, since it
110
+ * may belong to an app they are not using. Everything else on an entity is
111
+ * the user's own content and no structural operation should touch it.
112
+ */
113
+ export const PROVENANCE_KEYS = Object.freeze([
114
+ PROV.key,
115
+ PROV.sourceKey,
116
+ PROV.confirmedKey,
117
+ PROV.probKey,
118
+ PROV.detailKey,
119
+ ]);
120
+
102
121
  /** The four provenance states returned by provState. */
103
122
  export const PROV_STATES = Object.freeze({
104
123
  HUMAN: 'human',
package/src/roles.js CHANGED
@@ -25,6 +25,15 @@ export const PLAID_NAMESPACE = 'plaid';
25
25
  export const ROLE_KEY = 'role';
26
26
 
27
27
  /** The fixed role inventory. */
28
+ /**
29
+ * Layer config key naming the metadata keys a token born of a SPLIT inherits
30
+ * from the token it came from. Plaid honors it without knowing what the keys
31
+ * mean; see the manual's `Metadata Preserved Across a Split`. An app declares
32
+ * it so that a split in ANY app, including one that has never heard of these
33
+ * keys, does not silently drop them.
34
+ */
35
+ export const PRESERVE_ON_SPLIT_KEY = 'preserveOnSplit';
36
+
28
37
  export const ROLES = Object.freeze({
29
38
  BASELINE: 'baseline',
30
39
  SENTENCE: 'sentence',
@@ -21,7 +21,7 @@
21
21
  * parameters: [ // ordered; rendered into a form
22
22
  * { key, label, type, description?, default?, required?,
23
23
  * options?: [{value, label}], // enum / multiselect
24
- * min?, max?, step?, // number
24
+ * min?, max?, step?, slider?, // number (slider: drag, don't type)
25
25
  * placeholder?, multiline? } // string
26
26
  * ]
27
27
  * }
@@ -52,7 +52,17 @@ export const TASKS = Object.freeze({
52
52
  ANALYZE: "analyze",
53
53
  /** Propose a free translation for each sentence. */
54
54
  TRANSLATE: "translate",
55
- /** A conversational assistant over a project (chat turns; see plaid-igt-agent). */
55
+ /**
56
+ * Propose time-aligned speech regions for a recording.
57
+ *
58
+ * The odd one out: a detect-speech service RETURNS its regions and writes
59
+ * nothing, because a time-aligned segment is a stretch of the baseline and
60
+ * cannot exist without text. The response carries
61
+ * `{segments: [{timeBegin, timeEnd, speaker?}]}` (seconds), and the app
62
+ * holds them as proposals until a person types into one.
63
+ */
64
+ DETECT_SPEECH: "detect-speech",
65
+ /** A conversational assistant over a project (chat turns; see plaid-agent). */
56
66
  ASSIST: "assist",
57
67
  });
58
68
 
package/src/services.js CHANGED
@@ -147,31 +147,63 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
147
147
  ? { ...payload.data, requesterId }
148
148
  : payload.data;
149
149
 
150
+ const scope = createCancelScope(() => cancelled.get(requestId) === true);
150
151
  const responseHelper = {
151
152
  requestId,
152
153
  requesterId,
153
154
  // True once the requester asked for the request to stop.
154
155
  get cancelled() {
155
- return cancelled.get(requestId) === true;
156
+ return scope.cancelled;
156
157
  },
158
+ raiseIfCancelled: () => scope.raiseIfCancelled(),
159
+ critical: (fn) => scope.critical(fn),
157
160
  // Extra fields ride in the progress payload (a chat service sends the
158
161
  // reply text so far as `text`).
159
- progress: (percent, msg, extra) =>
160
- reportEvent(requestId, { status: 'progress', progress: { percent, message: msg, ...extra } }),
162
+ //
163
+ // This is a CANCELLATION CHECKPOINT: it throws ServiceCancelled if the
164
+ // requester has stopped the request, so a service that reports progress
165
+ // through its work is cancellable without doing anything else. Use
166
+ // `critical()` around writes to hold it off.
167
+ progress: (percent, msg, extra) => {
168
+ scope.raiseIfCancelled();
169
+ return reportEvent(requestId, {
170
+ status: 'progress',
171
+ progress: { percent, message: msg, ...extra },
172
+ });
173
+ },
161
174
  complete: (data) => {
162
175
  finished();
163
176
  return reportEvent(requestId, { status: 'completed', data });
164
177
  },
178
+ // Asked to stop, and it did. Reported as a result carrying
179
+ // `stopped: true`, so a client can tell "you stopped it" from "it broke".
180
+ stopped: (data) => {
181
+ finished();
182
+ return reportEvent(requestId, {
183
+ status: 'completed',
184
+ data: { ...(data || {}), stopped: true },
185
+ });
186
+ },
165
187
  error: (error) => {
166
188
  finished();
167
189
  return reportEvent(requestId, { status: 'error', data: { error: error?.message || error } });
168
190
  },
169
191
  };
170
192
 
193
+ // A handler may be async, so a rejected promise has to be caught too, or a
194
+ // cancelled async service would report a failure instead of a stop.
195
+ const settle = (error) => {
196
+ if (error instanceof ServiceCancelled || error?.name === 'ServiceCancelled') {
197
+ responseHelper.stopped();
198
+ } else {
199
+ responseHelper.error(error?.message || error);
200
+ }
201
+ };
171
202
  try {
172
- onServiceRequest(data, responseHelper);
203
+ const maybe = onServiceRequest(data, responseHelper);
204
+ if (maybe && typeof maybe.catch === 'function') maybe.catch(settle);
173
205
  } catch (error) {
174
- responseHelper.error(error?.message || error);
206
+ settle(error);
175
207
  }
176
208
  };
177
209
  const openChannel = () => client.messages.listen(projectId, onChannelEvent, channelPath);
@@ -204,7 +236,7 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
204
236
  *
205
237
  * Streams the service's progress + result back over a single server-mediated
206
238
  * response (no broadcast). Rejects if no service is connected (503), if the
207
- * service reports an error, or on timeout.
239
+ * service reports an error, or after `timeout` of SILENCE.
208
240
  *
209
241
  * The request outlives this call: after a timeout, an abort, or a dropped
210
242
  * connection the service goes on, and `attachServiceRequest` collects the
@@ -213,11 +245,15 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
213
245
  * to know the id before submitting; submitting an id that names a request
214
246
  * you already made rejoins it instead of starting another.
215
247
  *
248
+ * Errors that leave the request alive carry `pending: true`: a timeout, a
249
+ * stop, a dropped connection. An error without it is the end of the request.
250
+ *
216
251
  * @param {Object} client - PlaidClient instance
217
252
  * @param {string} projectId - Project UUID
218
253
  * @param {string} serviceId - Service ID to request
219
254
  * @param {any} data - Request payload
220
- * @param {number} [timeout=10000] - Timeout in ms
255
+ * @param {number} [timeout=10000] - How long the service may say NOTHING, in ms.
256
+ * Every event it sends starts the clock again, so this does not cap a long run
221
257
  * @param {function} [onProgress] - Called with each progress payload {percent, message}
222
258
  * @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
223
259
  * @param {Object} [opts]
@@ -259,12 +295,14 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
259
295
  * the latest progress is replayed, then the result comes, or at once if the
260
296
  * request already finished. Rejects with an error whose `status` is 404 when
261
297
  * the request is unknown or expired (the server keeps a finished request's
262
- * result for a while, not forever).
298
+ * result for a while, not forever). As with `requestService`, `timeout` is
299
+ * how long the service may be silent, and an error carrying `pending: true`
300
+ * means the request is still there to rejoin again.
263
301
  *
264
302
  * @param {Object} client - PlaidClient instance
265
303
  * @param {string} projectId - Project UUID
266
304
  * @param {string} requestId - The request id (from `onAccepted` or your own)
267
- * @param {number} [timeout=10000] - Timeout in ms
305
+ * @param {number} [timeout=10000] - How long the service may say NOTHING, in ms
268
306
  * @param {function} [onProgress] - Called with each progress payload {percent, message}
269
307
  * @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
270
308
  * @returns {Promise<any>} The service's result
@@ -293,10 +331,68 @@ export function attachServiceRequest(client, projectId, requestId, timeout = 100
293
331
  * @param {string} requestId - The request id
294
332
  * @returns {Promise<void>}
295
333
  */
334
+ /**
335
+ * Thrown inside a handler when the requester has asked it to stop.
336
+ *
337
+ * Cooperative cancellation: nothing interrupts a handler, so the request ends
338
+ * at the next point the handler looks. `responseHelper.progress` is that
339
+ * point, which is why a long service that already reports progress needs no
340
+ * changes at all. `serve` catches this and ends the request as STOPPED rather
341
+ * than failed.
342
+ */
343
+ export class ServiceCancelled extends Error {
344
+ constructor(message = 'The requester stopped this request') {
345
+ super(message);
346
+ this.name = 'ServiceCancelled';
347
+ }
348
+ }
349
+
350
+ /**
351
+ * The cancellation half of a responseHelper: a flag, a depth counter, and one
352
+ * rule about when a checkpoint may raise. Split from the transport so it can
353
+ * be reasoned about on its own. Mirrors `CancelScope` in the Python client.
354
+ */
355
+ export function createCancelScope(isCancelled) {
356
+ // Depth of nested `critical()` blocks; while non-zero, a checkpoint will
357
+ // not throw.
358
+ let criticalDepth = 0;
359
+ return {
360
+ get cancelled() {
361
+ return !!isCancelled();
362
+ },
363
+ /** Stop here if the requester has asked the request to stop. */
364
+ raiseIfCancelled() {
365
+ if (isCancelled() && criticalDepth === 0) throw new ServiceCancelled();
366
+ },
367
+ /**
368
+ * Run `fn` as a stretch that must finish once begun, usually the writes.
369
+ * Checkpoints inside do not throw, so a half-written document is never
370
+ * left behind; cancellation takes effect at the first checkpoint after.
371
+ */
372
+ async critical(fn) {
373
+ criticalDepth++;
374
+ try {
375
+ return await fn();
376
+ } finally {
377
+ criticalDepth--;
378
+ }
379
+ },
380
+ };
381
+ }
382
+
296
383
  export function cancelServiceRequest(client, projectId, requestId) {
297
384
  return client._request('DELETE', `/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}`);
298
385
  }
299
386
 
387
+ /**
388
+ * Mark an error as one that leaves the request ALIVE on the server: a timeout,
389
+ * a stop, a dropped connection. The service goes on working and its result
390
+ * still waits under the request id, so a caller holding that id can rejoin it
391
+ * with `attachServiceRequest`, and must not forget the id the way it forgets
392
+ * a request that really ended. An error without `pending` is terminal.
393
+ */
394
+ const stillRunning = (err) => Object.assign(err, { pending: true });
395
+
300
396
  /**
301
397
  * Open a request stream (submit or attach) and read it to its terminal
302
398
  * event: `accepted` names the request, `progress` events go to `onProgress`,
@@ -313,10 +409,23 @@ function streamServiceRequest(client, { url, method, body, onStatus, what }, tim
313
409
  abortController.abort();
314
410
  fn(arg);
315
411
  };
316
- const timer = setTimeout(
317
- () => finish(reject, new Error(`${what} timed out after ${timeout}ms`)),
318
- timeout,
319
- );
412
+
413
+ // `timeout` is IDLE time, meaning how long the service may say nothing. It
414
+ // is not a deadline on the run: every event from the server restarts it,
415
+ // because a service that is reporting its progress is not hung. As a
416
+ // deadline on the whole run it killed working transcriptions at the
417
+ // five-minute default and left their writes to land on a document the page
418
+ // had already handed back to the user as editable.
419
+ let timer;
420
+ const waitAgain = () => {
421
+ clearTimeout(timer);
422
+ timer = setTimeout(
423
+ () =>
424
+ finish(reject, stillRunning(new Error(`${what} timed out after ${timeout}ms of silence`))),
425
+ timeout,
426
+ );
427
+ };
428
+ waitAgain();
320
429
 
321
430
  // An external signal stops waiting on a long request (a UI's Stop button).
322
431
  // Reject with an AbortError so a caller can tell a deliberate stop from a
@@ -325,7 +434,7 @@ function streamServiceRequest(client, { url, method, body, onStatus, what }, tim
325
434
  const stop = () => {
326
435
  const err = new Error('The service request was stopped');
327
436
  err.name = 'AbortError';
328
- finish(reject, err);
437
+ finish(reject, stillRunning(err));
329
438
  };
330
439
  if (signal) {
331
440
  if (signal.aborted) {
@@ -349,7 +458,9 @@ function streamServiceRequest(client, { url, method, body, onStatus, what }, tim
349
458
  signal: abortController.signal,
350
459
  });
351
460
  } catch (error) {
352
- if (error.name !== 'AbortError') finish(reject, new Error(`${what} could not be sent: ${error.message}`));
461
+ // The POST may or may not have reached the server. Treat it as alive:
462
+ // rejoining a request that was never made simply 404s.
463
+ if (error.name !== 'AbortError') finish(reject, stillRunning(new Error(`${what} could not be sent: ${error.message}`)));
353
464
  return;
354
465
  }
355
466
 
@@ -386,6 +497,7 @@ function streamServiceRequest(client, { url, method, body, onStatus, what }, tim
386
497
  dataLine = line.slice(6);
387
498
  } else if (line === '' && eventType && dataLine) {
388
499
  const payload = transformResponse(JSON.parse(dataLine));
500
+ waitAgain(); // it spoke, so it is not hung
389
501
  if (eventType === 'accepted') {
390
502
  if (onAccepted) { try { onAccepted(payload.requestId); } catch (_) { /* ignore */ } }
391
503
  } else if (eventType === 'progress') {
@@ -402,9 +514,9 @@ function streamServiceRequest(client, { url, method, body, onStatus, what }, tim
402
514
  }
403
515
  }
404
516
  }
405
- finish(reject, new Error('Service closed the connection without a result'));
517
+ finish(reject, stillRunning(new Error('Service closed the connection without a result')));
406
518
  } catch (error) {
407
- if (error.name !== 'AbortError') finish(reject, new Error(`${what} stream error: ${error.message}`));
519
+ if (error.name !== 'AbortError') finish(reject, stillRunning(new Error(`${what} stream error: ${error.message}`)));
408
520
  }
409
521
  })();
410
522
  });