@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 +212 -14
- package/package.json +1 -1
- package/src/documentLock.js +299 -0
- package/src/http.js +143 -53
- package/src/index.js +387 -151
- package/src/pagination.js +19 -35
- package/src/serviceSchema.js +27 -1
- package/src/services.js +137 -21
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?:
|
|
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
|
-
//
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
@@ -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
|
+
}
|