@larc-iu/plaid-client 0.2.0-alpha.13 → 0.2.0-alpha.16

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
@@ -15,7 +15,7 @@ interface ServiceParam {
15
15
  /** Key the value is sent under in the request payload. */
16
16
  key: string;
17
17
  label: string;
18
- type: "string" | "number" | "boolean" | "enum" | "multiselect";
18
+ type: "string" | "number" | "boolean" | "enum" | "multiselect" | "field";
19
19
  description?: string;
20
20
  default?: any;
21
21
  required?: boolean;
@@ -30,6 +30,11 @@ interface ServiceParam {
30
30
  /** `string` only. */
31
31
  placeholder?: string;
32
32
  multiline?: boolean;
33
+ /**
34
+ * `field` only: the scope of the project's annotation fields the value names
35
+ * one of. A `field` value is a string everywhere else.
36
+ */
37
+ scope?: "Sentence" | "Word" | "Morpheme";
33
38
  }
34
39
 
35
40
  /** A service's standardized self-description (lives in `extras`). */
@@ -201,6 +206,7 @@ interface RelationsBundle {
201
206
  ): Promise<any>;
202
207
  bulkCreate(body: any[], auditMessage?: string): Promise<{ ids: string[] }>;
203
208
  bulkDelete(body: any[], auditMessage?: string): Promise<void>;
209
+ bulkUpdate(body: any[], auditMessage?: string): Promise<{ count: number }>;
204
210
  }
205
211
 
206
212
  interface SpanLayersBundle {
@@ -250,15 +256,12 @@ interface SpansBundle {
250
256
  update(spanId: string, value: any, auditMessage?: string): Promise<any>;
251
257
  bulkCreate(body: any[], auditMessage?: string): Promise<{ ids: string[] }>;
252
258
  bulkDelete(body: any[], auditMessage?: string): Promise<void>;
259
+ bulkUpdate(body: any[], auditMessage?: string): Promise<{ count: number }>;
253
260
  setMetadata(spanId: string, body: any, auditMessage?: string): Promise<any>;
254
261
  deleteMetadata(spanId: string, auditMessage?: string): Promise<any>;
255
262
  patchMetadata(spanId: string, body: any, auditMessage?: string): Promise<any>;
256
263
  }
257
264
 
258
- interface BatchBundle {
259
- submit(body: any[], auditMessage?: string): Promise<any>;
260
- }
261
-
262
265
  interface TextsBundle {
263
266
  setMetadata(textId: string, body: any, auditMessage?: string): Promise<any>;
264
267
  deleteMetadata(textId: string, auditMessage?: string): Promise<any>;
@@ -320,12 +323,26 @@ interface UserDataEntry {
320
323
  value?: any;
321
324
  }
322
325
 
326
+ interface UserDataOptions {
327
+ prefix?: string;
328
+ pattern?: string;
329
+ includeValues?: boolean;
330
+ }
331
+
323
332
  /** Private per-user key/value storage (owner or admin only; not audited). */
324
333
  interface UserDataBundle {
325
334
  list(
326
335
  userId: string,
327
- opts?: { prefix?: string; includeValues?: boolean },
336
+ opts?: UserDataOptions & { pageSize?: number },
328
337
  ): Promise<UserDataEntry[]>;
338
+ listPage(
339
+ userId: string,
340
+ opts?: UserDataOptions & { limit?: number; cursor?: string },
341
+ ): Promise<Page<UserDataEntry>>;
342
+ iterPages(
343
+ userId: string,
344
+ opts?: UserDataOptions & { pageSize?: number },
345
+ ): AsyncGenerator<UserDataEntry[]>;
329
346
  get(userId: string, key: string): Promise<UserDataEntry & { value: any }>;
330
347
  put(
331
348
  userId: string,
@@ -345,6 +362,60 @@ interface CommentFilters {
345
362
  type CommentableType =
346
363
  "document" | "text" | "token" | "span" | "relation" | "vocab-item";
347
364
 
365
+ /**
366
+ * One entry in a project's annotation manual: a short Markdown document
367
+ * stating a convention the project follows. Read by the people on the
368
+ * project, and by the assistant before it proposes anything.
369
+ */
370
+ interface Guideline {
371
+ id: string;
372
+ projectId: string;
373
+ /** The handle the assistant asks for a guideline by. Not required to be unique. */
374
+ title: string;
375
+ /** The Markdown text. Present on a single read and on a list made with `includeBodies`. */
376
+ body?: string;
377
+ /** The body's length in characters. Present on a list made WITHOUT `includeBodies`. */
378
+ bodyChars?: number;
379
+ /** Whether the assistant is given this one in full on every turn. */
380
+ pinned: boolean;
381
+ createdAt: string;
382
+ updatedAt: string;
383
+ }
384
+
385
+ interface GuidelinesBundle {
386
+ create(
387
+ projectId: string,
388
+ title: string,
389
+ opts?: { body?: string; pinned?: boolean },
390
+ auditMessage?: string,
391
+ ): Promise<{ id: string }>;
392
+ get(id: string): Promise<Guideline>;
393
+ update(
394
+ id: string,
395
+ changes?: {
396
+ title?: string;
397
+ body?: string;
398
+ pinned?: boolean;
399
+ /** Write only if this is still the stored updatedAt; 409 otherwise. */
400
+ expectedUpdatedAt?: string;
401
+ },
402
+ auditMessage?: string,
403
+ ): Promise<Guideline>;
404
+ delete(id: string, auditMessage?: string): Promise<any>;
405
+ list(
406
+ projectId: string,
407
+ opts?: { includeBodies?: boolean },
408
+ ): Promise<Guideline[]>;
409
+ listPage(
410
+ projectId: string,
411
+ opts?: { includeBodies?: boolean; limit?: number; cursor?: string },
412
+ ): Promise<Page<Guideline>>;
413
+ iterPages(
414
+ projectId: string,
415
+ opts?: { includeBodies?: boolean; pageSize?: number },
416
+ ): AsyncGenerator<Guideline[]>;
417
+ }
418
+
348
419
  interface Comment {
349
420
  id: string;
350
421
  /** The owning project, or null for a comment on a vocabulary entry. */
@@ -568,6 +639,14 @@ interface AdminBundle {
568
639
  userId?: string;
569
640
  }): Promise<{ result: string }>;
570
641
  logs(opts?: {
642
+ limit?: number;
643
+ q?: string;
644
+ level?: string;
645
+ status?: string;
646
+ user?: string;
647
+ method?: string;
648
+ }): Promise<LiveLog>;
649
+ logFile(opts?: {
571
650
  lines?: number;
572
651
  }): Promise<{ file: string | null; lines: string[]; error?: string }>;
573
652
  userData(opts?: AdminUserDataOptions): Promise<AdminUserDataEntry[]>;
@@ -576,6 +655,71 @@ interface AdminBundle {
576
655
  ): Promise<Page<AdminUserDataEntry>>;
577
656
  }
578
657
 
658
+ /** One HTTP request as the log buffer holds it. */
659
+ interface LoggedRequest {
660
+ /** Epoch milliseconds. */
661
+ ts: number;
662
+ method: string;
663
+ path: string;
664
+ /** Query string with sensitive values replaced, or null. */
665
+ query: string | null;
666
+ /** Null when the handler threw before producing one. */
667
+ status: number | null;
668
+ ms: number;
669
+ /** Null for a request nobody was authenticated for. */
670
+ user: string | null;
671
+ ip: string | null;
672
+ /** Set when a named API token signed the request. */
673
+ token: string | null;
674
+ /** Exception class, when the handler threw. */
675
+ error: string | null;
676
+ }
677
+
678
+ /** One log event that was not a request. */
679
+ interface LoggedEvent {
680
+ ts: number;
681
+ level: string;
682
+ ns: string;
683
+ message: string;
684
+ trace?: string;
685
+ }
686
+
687
+ /**
688
+ * The live log. Requests and events are buffered separately, so a burst of
689
+ * requests cannot evict an error.
690
+ */
691
+ interface LiveLog {
692
+ requests: {
693
+ entries: LoggedRequest[];
694
+ /** How many passed the filters, which may exceed what `entries` holds. */
695
+ matched: number;
696
+ /** How many are buffered in total. */
697
+ held: number;
698
+ capacity: number;
699
+ stats: {
700
+ count: number;
701
+ failures: number;
702
+ serverErrors: number;
703
+ p50: number | null;
704
+ p95: number | null;
705
+ max: number | null;
706
+ perMinute: number | null;
707
+ oldest: number | null;
708
+ newest: number | null;
709
+ };
710
+ };
711
+ events: {
712
+ entries: LoggedEvent[];
713
+ matched: number;
714
+ held: number;
715
+ capacity: number;
716
+ /** Counts per level of what the search matched, before the level filter. */
717
+ byLevel: Record<string, number>;
718
+ };
719
+ /** Absolute path of the configured log file, or null when logging to stdout. */
720
+ file: string | null;
721
+ }
722
+
579
723
  /** Narrowings for the cross-account private-data listing. */
580
724
  interface AdminUserDataOptions {
581
725
  /** Only keys starting with this literal head. */
@@ -692,10 +836,39 @@ interface TokenLayersBundle {
692
836
  ): Promise<any>;
693
837
  }
694
838
 
839
+ /** Handle passed to a `documents.locked()` block. */
840
+ export declare class DocumentLock {
841
+ readonly documentId: string;
842
+ /** The DocumentLockLost if the lock lapsed, else null. */
843
+ readonly lost: DocumentLockLost | null;
844
+ /** Throw if the lock lapsed; call it between steps that have not written. */
845
+ raiseIfLost(): void;
846
+ }
847
+
848
+ /** The lock a `documents.locked()` block was holding is no longer held. */
849
+ export declare class DocumentLockLost extends Error {
850
+ readonly name: "DocumentLockLost";
851
+ readonly documentId: string;
852
+ readonly cause?: unknown;
853
+ }
854
+
855
+ /** The server's default document-lock window, in ms. */
856
+ export const DOCUMENT_LOCK_TTL_MS: 60000;
857
+
695
858
  interface DocumentsBundle {
696
859
  checkLock(documentId: string, asOf?: string): Promise<any>;
697
860
  acquireLock(documentId: string, auditMessage?: string): Promise<any>;
698
861
  releaseLock(documentId: string, auditMessage?: string): Promise<any>;
862
+ /**
863
+ * Hold the document's lock for the length of `fn`, renewing it while `fn`
864
+ * runs and releasing it on the way out. Rejects with a 423 if another user
865
+ * holds it, and with DocumentLockLost if a renewal fails.
866
+ */
867
+ locked<T>(
868
+ documentId: string,
869
+ fn: (lock: DocumentLock) => T | Promise<T>,
870
+ options?: { keepAlive?: boolean },
871
+ ): Promise<T>;
699
872
  getMedia(documentId: string): Promise<ArrayBuffer>;
700
873
  uploadMedia(
701
874
  documentId: string,
@@ -894,6 +1067,7 @@ interface VocabItemsBundle {
894
1067
  ): Promise<any>;
895
1068
  bulkCreate(body: any[], auditMessage?: string): Promise<{ ids: string[] }>;
896
1069
  bulkDelete(body: any[], auditMessage?: string): Promise<void>;
1070
+ bulkUpdate(body: any[], auditMessage?: string): Promise<{ count: number }>;
897
1071
  get(id: string, asOf?: string): Promise<any>;
898
1072
  delete(id: string, auditMessage?: string): Promise<any>;
899
1073
  update(id: string, form: string, auditMessage?: string): Promise<any>;
@@ -953,6 +1127,7 @@ interface TokensBundle {
953
1127
  ): Promise<any>;
954
1128
  bulkCreate(body: any[], auditMessage?: string): Promise<{ ids: string[] }>;
955
1129
  bulkDelete(body: any[], auditMessage?: string): Promise<void>;
1130
+ bulkUpdate(body: any[], auditMessage?: string): Promise<{ count: number }>;
956
1131
  split(tokenId: string, position: number, auditMessage?: string): Promise<any>;
957
1132
  merge(
958
1133
  tokenId: string,
@@ -997,6 +1172,21 @@ export interface OperationGroupsBundle {
997
1172
  update(id: string, message: string | null): Promise<any>;
998
1173
  }
999
1174
 
1175
+ /**
1176
+ * A batch: the client's bundles, on which every write of project data queues
1177
+ * until `submit()`. See `PlaidClient.batch()`.
1178
+ */
1179
+ export interface PlaidBatch extends PlaidClient {
1180
+ /** The client this batch was opened on. */
1181
+ readonly client: PlaidClient;
1182
+ /** The queued operations, in order. */
1183
+ readonly operations: Array<{ path: string; method: string; body?: any }>;
1184
+ /** Send the queued operations as one atomic request; one result per operation. */
1185
+ submit(): Promise<any[]>;
1186
+ /** Drop the queued operations without sending them. */
1187
+ abort(): void;
1188
+ }
1189
+
1000
1190
  export declare class PlaidClient {
1001
1191
  constructor(baseUrl: string, token: string, options?: PlaidClientOptions);
1002
1192
  static login(
@@ -1025,13 +1215,20 @@ export declare class PlaidClient {
1025
1215
  /** Fired once on HTTP 401 (see PlaidClientOptions.onAuthError). */
1026
1216
  onAuthError: ((error: Error) => void) | null;
1027
1217
 
1028
- // Batch control methods
1029
- beginBatch(): void;
1030
- submitBatch(): Promise<any[]>;
1031
- abortBatch(): void;
1032
- isBatchMode(): boolean;
1033
- /** Run `fn` with a batch open, then submit atomically (or abort if `fn` throws). Resolves to the results array. */
1034
- batched(fn: () => void | Promise<void>): Promise<any[]>;
1218
+ // Batches.
1219
+ //
1220
+ // A batch is a view of the client with the same bundles. A write of project
1221
+ // data made on the batch queues; a call made on the client itself always
1222
+ // goes over the wire, whatever batches are open, so a write by code that
1223
+ // knows nothing about a batch can never land inside it. A read, or a signal
1224
+ // that carries no project data (stopping a service request, a service
1225
+ // reporting its progress, taking and dropping a document lock, the admin
1226
+ // actions on the server itself), goes over the wire even when made on the
1227
+ // batch.
1228
+ /** Open a batch. Queue writes on it, then `submit()` them as one atomic request, or `abort()`. Not nestable. */
1229
+ batch(): PlaidBatch;
1230
+ /** Run `fn` with a batch, then submit atomically (or abort if `fn` throws). Resolves to the results array, one entry per queued write. */
1231
+ batched(fn: (batch: PlaidBatch) => void | Promise<void>): Promise<any[]>;
1035
1232
 
1036
1233
  // Strict mode methods
1037
1234
  enterStrictMode(documentId: string): void;
@@ -1057,7 +1254,6 @@ export declare class PlaidClient {
1057
1254
  relations: RelationsBundle;
1058
1255
  spanLayers: SpanLayersBundle;
1059
1256
  spans: SpansBundle;
1060
- batch: BatchBundle;
1061
1257
  texts: TextsBundle;
1062
1258
  users: UsersBundle;
1063
1259
  apiTokens: ApiTokensBundle;
@@ -1067,6 +1263,7 @@ export declare class PlaidClient {
1067
1263
  admin: AdminBundle;
1068
1264
  audit: AuditBundle;
1069
1265
  comments: CommentsBundle;
1266
+ guidelines: GuidelinesBundle;
1070
1267
  tokenLayers: TokenLayersBundle;
1071
1268
  documents: DocumentsBundle;
1072
1269
  messages: MessagesBundle;
@@ -1136,6 +1333,7 @@ export const TASKS: {
1136
1333
  readonly TRANSLATE: "translate";
1137
1334
  readonly ASSIST: "assist";
1138
1335
  readonly DETECT_SPEECH: "detect-speech";
1336
+ readonly COMPARE: "compare";
1139
1337
  };
1140
1338
  /** Whether a service serves a task (declared `extras.tasks`, legacy id-prefix fallback). */
1141
1339
  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.13",
3
+ "version": "0.2.0-alpha.16",
4
4
  "description": "JavaScript client for the Plaid annotation API",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Keeping a document lock alive for as long as a `locked()` block runs.
3
+ *
4
+ * plaid-core expires a document lock about a minute after it was taken, and the
5
+ * holder's own writes are what renew it (`plaid.sql.operation` calls
6
+ * `refresh-locks!` on every write it accepts). That is the right rule for an
7
+ * editor, which writes as the person types. It is the wrong one for a service:
8
+ * a parser loads a model, reads the document, spends minutes in inference and
9
+ * only then writes, so for most of its run it holds nothing. The lock lapses in
10
+ * silence, a person's edit lands between the read the work was planned from and
11
+ * the write about to go out, and the write clobbers it.
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.
15
+ *
16
+ * The beat is unconditional rather than "only when no write has gone out
17
+ * recently". The request layer knows the method of a call but not which
18
+ * document it touched, so a write to some other document would count as a
19
+ * renewal here and would not be one. Two extra requests a minute is the whole
20
+ * cost.
21
+ *
22
+ * A renewal that fails means the block is no longer holding what it asked for,
23
+ * so it stops the run: the loss is recorded on the client and every later write
24
+ * throws `DocumentLockLost`. The guard sits in the request layer because the
25
+ * callers are services, and a check they have to remember to call is a check
26
+ * that is missing somewhere.
27
+ */
28
+
29
+ /**
30
+ * How long plaid-core holds a document lock before it expires, in ms.
31
+ * `plaid.server.locks/default-lock-expiration-ms`. An operator can change it
32
+ * with `:plaid.server.locks/config :expiration-ms`, and a server publishes what
33
+ * it enforces as `lockExpirationMs` in `GET /info`. This is the last resort:
34
+ * what a live lock is renewed against is the `expiresAt` on the acquire
35
+ * response, which names the moment rather than the window.
36
+ */
37
+ export const DOCUMENT_LOCK_TTL_MS = 60000;
38
+
39
+ // Widest lock lifetime we will believe from a server response. Past this the
40
+ // number is a clock skew between this machine and the server rather than a
41
+ // configured window, and the documented default is the better guess.
42
+ const MAX_BELIEVABLE_TTL_MS = 3600000;
43
+
44
+ /**
45
+ * The lock a `documents.locked()` block was holding is no longer held.
46
+ *
47
+ * Thrown by the keep-alive when it cannot renew the lock, and then by the
48
+ * request layer on every write this client attempts, so work that has been
49
+ * running for minutes stops rather than writing over an edit that may have
50
+ * landed while the lock was gone.
51
+ */
52
+ export class DocumentLockLost extends Error {
53
+ constructor(message, documentId, cause) {
54
+ super(message);
55
+ this.name = "DocumentLockLost";
56
+ this.documentId = documentId;
57
+ this.cause = cause;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * How long a freshly taken lock lasts, from the server's own answer.
63
+ *
64
+ * `expiresAt` is the epoch-millisecond stamp the lock endpoints return.
65
+ * Comparing it against this machine's clock is the only way to learn a window
66
+ * an operator has retuned, and it is also the one place a clock skew can get
67
+ * in, so an answer outside a believable band falls back to the documented
68
+ * default rather than to a beat that never fires or fires constantly.
69
+ *
70
+ * @param {number|undefined} expiresAt
71
+ * @param {number} nowMs
72
+ * @param {number} [fallback]
73
+ * @returns {number}
74
+ */
75
+ export function lockTtlMs(
76
+ expiresAt,
77
+ nowMs,
78
+ fallback = DOCUMENT_LOCK_TTL_MS,
79
+ ) {
80
+ if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) {
81
+ const ttl = expiresAt - nowMs;
82
+ if (ttl > 0 && ttl <= MAX_BELIEVABLE_TTL_MS) return ttl;
83
+ }
84
+ return fallback;
85
+ }
86
+
87
+ /**
88
+ * Renews one document's lock until the block holding it exits.
89
+ *
90
+ * `refresh` is called with the document id and must reject on failure.
91
+ * `onLost` is called once, with the `DocumentLockLost`, when the lock can no
92
+ * longer be assumed.
93
+ *
94
+ * `clock` and `sleep` exist so the whole policy can be run on a fake clock:
95
+ * `sleep(delayMs)` resolves to true once the block has exited.
96
+ */
97
+ export class LockKeeper {
98
+ constructor(
99
+ refresh,
100
+ documentId,
101
+ ttlMs,
102
+ { onLost = null, clock = Date.now, sleep = null } = {},
103
+ ) {
104
+ this._refresh = refresh;
105
+ this._documentId = documentId;
106
+ // A window under two seconds leaves no room for a retry; treat it as a
107
+ // misconfiguration and beat at the documented rate instead.
108
+ this._ttlMs = ttlMs >= 2000 ? ttlMs : DOCUMENT_LOCK_TTL_MS;
109
+ this._onLost = onLost;
110
+ this._clock = clock;
111
+ this._sleep = sleep || ((ms) => this._defaultSleep(ms));
112
+ this._stopped = false;
113
+ this._wake = null;
114
+ this._running = null;
115
+ /** The `DocumentLockLost` this keeper raised, or null. */
116
+ this.lost = null;
117
+ }
118
+
119
+ /**
120
+ * Time between renewals: half the window, so a renewal that fails has a
121
+ * second chance before the lock it is renewing expires.
122
+ */
123
+ get intervalMs() {
124
+ return Math.max(1000, this._ttlMs / 2);
125
+ }
126
+
127
+ /**
128
+ * Time before retrying a renewal that failed. A blip should not end a run
129
+ * that the server still considers the holder of.
130
+ */
131
+ get retryMs() {
132
+ return Math.max(500, this._ttlMs / 10);
133
+ }
134
+
135
+ _defaultSleep(ms) {
136
+ if (this._stopped) return Promise.resolve(true);
137
+ return new Promise((resolve) => {
138
+ const timer = setTimeout(() => {
139
+ this._wake = null;
140
+ resolve(this._stopped);
141
+ }, ms);
142
+ // Never hold a Node process open on the beat alone.
143
+ if (timer && typeof timer.unref === "function") timer.unref();
144
+ this._wake = () => {
145
+ clearTimeout(timer);
146
+ this._wake = null;
147
+ resolve(true);
148
+ };
149
+ });
150
+ }
151
+
152
+ /** The beat. Resolves when the block exits or the lock is lost. */
153
+ async run() {
154
+ let deadline = this._clock() + this._ttlMs;
155
+ let delay = this.intervalMs;
156
+ while (!(await this._sleep(delay))) {
157
+ try {
158
+ await this._refresh(this._documentId);
159
+ } 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.
163
+ if (error?.status === 423 || this._clock() >= deadline) {
164
+ this._fail(error);
165
+ return;
166
+ }
167
+ delay = this.retryMs;
168
+ continue;
169
+ }
170
+ deadline = this._clock() + this._ttlMs;
171
+ delay = this.intervalMs;
172
+ }
173
+ }
174
+
175
+ start() {
176
+ this._running = this.run();
177
+ // Nothing awaits the beat: a failure inside it is reported through onLost,
178
+ // not through this promise.
179
+ this._running.catch(() => {});
180
+ return this._running;
181
+ }
182
+
183
+ stop() {
184
+ this._stopped = true;
185
+ if (this._wake) this._wake();
186
+ }
187
+
188
+ _fail(error) {
189
+ this.lost = new DocumentLockLost(
190
+ `The lock on document ${this._documentId} lapsed: it could not be renewed.`,
191
+ this._documentId,
192
+ error,
193
+ );
194
+ if (this._onLost) this._onLost(this.lost);
195
+ }
196
+ }
197
+
198
+ /**
199
+ * What a `client.documents.locked(id, (lock) => ...)` block gets.
200
+ *
201
+ * The block does not have to consult it: a lost lock stops the next write on
202
+ * its own. Read `lock.lost` (or call `lock.raiseIfLost()`) to give up earlier,
203
+ * between steps of work that has not written anything yet.
204
+ */
205
+ export class DocumentLock {
206
+ constructor(documentId, keeper) {
207
+ this.documentId = documentId;
208
+ this._keeper = keeper;
209
+ }
210
+
211
+ /** The `DocumentLockLost` if the lock lapsed, else null. */
212
+ get lost() {
213
+ return this._keeper ? this._keeper.lost : null;
214
+ }
215
+
216
+ raiseIfLost() {
217
+ if (this.lost) throw this.lost;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Hold `documentId`'s server-enforced lock for the length of `fn`.
223
+ * `client.documents.locked` is the entry point; see its doc comment.
224
+ *
225
+ * @param {object} client
226
+ * @param {string} documentId
227
+ * @param {(lock: DocumentLock) => any} fn
228
+ * @param {{ keepAlive?: boolean }} [options]
229
+ */
230
+ export async function withDocumentLock(
231
+ client,
232
+ documentId,
233
+ fn,
234
+ { keepAlive = true } = {},
235
+ ) {
236
+ let info;
237
+ try {
238
+ info = await client.documents.acquireLock(documentId);
239
+ } catch (error) {
240
+ if (error?.status === 423) {
241
+ // The error body is raw JSON off the wire, so it is still kebab-cased.
242
+ const body = error.responseData || {};
243
+ const holder = body.userId || body["user-id"] || "another user";
244
+ const readable = new Error(
245
+ `Document ${documentId} is locked by ${holder} (likely being edited); try again once they're done.`,
246
+ );
247
+ readable.status = 423;
248
+ readable.statusText = error.statusText;
249
+ readable.url = error.url;
250
+ readable.method = error.method;
251
+ readable.responseData = error.responseData;
252
+ readable.cause = error;
253
+ throw readable;
254
+ }
255
+ throw error;
256
+ }
257
+
258
+ let keeper = null;
259
+ if (keepAlive) {
260
+ const ttlMs = lockTtlMs(info?.expiresAt, Date.now());
261
+ client.documentLockLost = null;
262
+ keeper = new LockKeeper(
263
+ (id) => client.documents.acquireLock(id),
264
+ documentId,
265
+ ttlMs,
266
+ {
267
+ onLost: (lost) => {
268
+ client.documentLockLost = lost;
269
+ },
270
+ },
271
+ );
272
+ keeper.start();
273
+ }
274
+
275
+ let result;
276
+ let threw = false;
277
+ try {
278
+ result = await fn(new DocumentLock(documentId, keeper));
279
+ } catch (error) {
280
+ threw = true;
281
+ throw error;
282
+ } finally {
283
+ if (keeper) keeper.stop();
284
+ const lost = keeper ? keeper.lost : null;
285
+ client.documentLockLost = null;
286
+ // Best-effort release: the server TTL reclaims a stranded lock, and we must
287
+ // not let a release failure mask the real error from the body.
288
+ try {
289
+ await client.documents.releaseLock(documentId);
290
+ } catch {
291
+ /* the lock expires on its own */
292
+ }
293
+ // A block that ran to the end without the lock it asked for did not do what
294
+ // it says it did. Only throw when nothing else is already propagating, so
295
+ // the real failure is never masked.
296
+ if (lost && !threw) throw lost;
297
+ }
298
+ return result;
299
+ }