@larc-iu/plaid-client 0.2.0-alpha.12 → 0.2.0-alpha.14
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 +132 -3
- package/package.json +1 -1
- package/src/documentLock.js +299 -0
- package/src/http.js +87 -14
- package/src/index.js +268 -47
- package/src/pagination.js +23 -35
- package/src/services.js +145 -21
package/index.d.ts
CHANGED
|
@@ -201,6 +201,7 @@ interface RelationsBundle {
|
|
|
201
201
|
): Promise<any>;
|
|
202
202
|
bulkCreate(body: any[], auditMessage?: string): Promise<{ ids: string[] }>;
|
|
203
203
|
bulkDelete(body: any[], auditMessage?: string): Promise<void>;
|
|
204
|
+
bulkUpdate(body: any[], auditMessage?: string): Promise<{ count: number }>;
|
|
204
205
|
}
|
|
205
206
|
|
|
206
207
|
interface SpanLayersBundle {
|
|
@@ -250,6 +251,7 @@ interface SpansBundle {
|
|
|
250
251
|
update(spanId: string, value: any, auditMessage?: string): Promise<any>;
|
|
251
252
|
bulkCreate(body: any[], auditMessage?: string): Promise<{ ids: string[] }>;
|
|
252
253
|
bulkDelete(body: any[], auditMessage?: string): Promise<void>;
|
|
254
|
+
bulkUpdate(body: any[], auditMessage?: string): Promise<{ count: number }>;
|
|
253
255
|
setMetadata(spanId: string, body: any, auditMessage?: string): Promise<any>;
|
|
254
256
|
deleteMetadata(spanId: string, auditMessage?: string): Promise<any>;
|
|
255
257
|
patchMetadata(spanId: string, body: any, auditMessage?: string): Promise<any>;
|
|
@@ -320,12 +322,26 @@ interface UserDataEntry {
|
|
|
320
322
|
value?: any;
|
|
321
323
|
}
|
|
322
324
|
|
|
325
|
+
interface UserDataOptions {
|
|
326
|
+
prefix?: string;
|
|
327
|
+
pattern?: string;
|
|
328
|
+
includeValues?: boolean;
|
|
329
|
+
}
|
|
330
|
+
|
|
323
331
|
/** Private per-user key/value storage (owner or admin only; not audited). */
|
|
324
332
|
interface UserDataBundle {
|
|
325
333
|
list(
|
|
326
334
|
userId: string,
|
|
327
|
-
opts?:
|
|
335
|
+
opts?: UserDataOptions & { pageSize?: number },
|
|
328
336
|
): Promise<UserDataEntry[]>;
|
|
337
|
+
listPage(
|
|
338
|
+
userId: string,
|
|
339
|
+
opts?: UserDataOptions & { limit?: number; cursor?: string },
|
|
340
|
+
): Promise<Page<UserDataEntry>>;
|
|
341
|
+
iterPages(
|
|
342
|
+
userId: string,
|
|
343
|
+
opts?: UserDataOptions & { pageSize?: number },
|
|
344
|
+
): AsyncGenerator<UserDataEntry[]>;
|
|
329
345
|
get(userId: string, key: string): Promise<UserDataEntry & { value: any }>;
|
|
330
346
|
put(
|
|
331
347
|
userId: string,
|
|
@@ -568,6 +584,14 @@ interface AdminBundle {
|
|
|
568
584
|
userId?: string;
|
|
569
585
|
}): Promise<{ result: string }>;
|
|
570
586
|
logs(opts?: {
|
|
587
|
+
limit?: number;
|
|
588
|
+
q?: string;
|
|
589
|
+
level?: string;
|
|
590
|
+
status?: string;
|
|
591
|
+
user?: string;
|
|
592
|
+
method?: string;
|
|
593
|
+
}): Promise<LiveLog>;
|
|
594
|
+
logFile(opts?: {
|
|
571
595
|
lines?: number;
|
|
572
596
|
}): Promise<{ file: string | null; lines: string[]; error?: string }>;
|
|
573
597
|
userData(opts?: AdminUserDataOptions): Promise<AdminUserDataEntry[]>;
|
|
@@ -576,6 +600,71 @@ interface AdminBundle {
|
|
|
576
600
|
): Promise<Page<AdminUserDataEntry>>;
|
|
577
601
|
}
|
|
578
602
|
|
|
603
|
+
/** One HTTP request as the log buffer holds it. */
|
|
604
|
+
interface LoggedRequest {
|
|
605
|
+
/** Epoch milliseconds. */
|
|
606
|
+
ts: number;
|
|
607
|
+
method: string;
|
|
608
|
+
path: string;
|
|
609
|
+
/** Query string with sensitive values replaced, or null. */
|
|
610
|
+
query: string | null;
|
|
611
|
+
/** Null when the handler threw before producing one. */
|
|
612
|
+
status: number | null;
|
|
613
|
+
ms: number;
|
|
614
|
+
/** Null for a request nobody was authenticated for. */
|
|
615
|
+
user: string | null;
|
|
616
|
+
ip: string | null;
|
|
617
|
+
/** Set when a named API token signed the request. */
|
|
618
|
+
token: string | null;
|
|
619
|
+
/** Exception class, when the handler threw. */
|
|
620
|
+
error: string | null;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** One log event that was not a request. */
|
|
624
|
+
interface LoggedEvent {
|
|
625
|
+
ts: number;
|
|
626
|
+
level: string;
|
|
627
|
+
ns: string;
|
|
628
|
+
message: string;
|
|
629
|
+
trace?: string;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* The live log. Requests and events are buffered separately, so a burst of
|
|
634
|
+
* requests cannot evict an error.
|
|
635
|
+
*/
|
|
636
|
+
interface LiveLog {
|
|
637
|
+
requests: {
|
|
638
|
+
entries: LoggedRequest[];
|
|
639
|
+
/** How many passed the filters, which may exceed what `entries` holds. */
|
|
640
|
+
matched: number;
|
|
641
|
+
/** How many are buffered in total. */
|
|
642
|
+
held: number;
|
|
643
|
+
capacity: number;
|
|
644
|
+
stats: {
|
|
645
|
+
count: number;
|
|
646
|
+
failures: number;
|
|
647
|
+
serverErrors: number;
|
|
648
|
+
p50: number | null;
|
|
649
|
+
p95: number | null;
|
|
650
|
+
max: number | null;
|
|
651
|
+
perMinute: number | null;
|
|
652
|
+
oldest: number | null;
|
|
653
|
+
newest: number | null;
|
|
654
|
+
};
|
|
655
|
+
};
|
|
656
|
+
events: {
|
|
657
|
+
entries: LoggedEvent[];
|
|
658
|
+
matched: number;
|
|
659
|
+
held: number;
|
|
660
|
+
capacity: number;
|
|
661
|
+
/** Counts per level of what the search matched, before the level filter. */
|
|
662
|
+
byLevel: Record<string, number>;
|
|
663
|
+
};
|
|
664
|
+
/** Absolute path of the configured log file, or null when logging to stdout. */
|
|
665
|
+
file: string | null;
|
|
666
|
+
}
|
|
667
|
+
|
|
579
668
|
/** Narrowings for the cross-account private-data listing. */
|
|
580
669
|
interface AdminUserDataOptions {
|
|
581
670
|
/** Only keys starting with this literal head. */
|
|
@@ -692,10 +781,39 @@ interface TokenLayersBundle {
|
|
|
692
781
|
): Promise<any>;
|
|
693
782
|
}
|
|
694
783
|
|
|
784
|
+
/** Handle passed to a `documents.locked()` block. */
|
|
785
|
+
export declare class DocumentLock {
|
|
786
|
+
readonly documentId: string;
|
|
787
|
+
/** The DocumentLockLost if the lock lapsed, else null. */
|
|
788
|
+
readonly lost: DocumentLockLost | null;
|
|
789
|
+
/** Throw if the lock lapsed; call it between steps that have not written. */
|
|
790
|
+
raiseIfLost(): void;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** The lock a `documents.locked()` block was holding is no longer held. */
|
|
794
|
+
export declare class DocumentLockLost extends Error {
|
|
795
|
+
readonly name: "DocumentLockLost";
|
|
796
|
+
readonly documentId: string;
|
|
797
|
+
readonly cause?: unknown;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** The server's default document-lock window, in ms. */
|
|
801
|
+
export const DOCUMENT_LOCK_TTL_MS: 60000;
|
|
802
|
+
|
|
695
803
|
interface DocumentsBundle {
|
|
696
804
|
checkLock(documentId: string, asOf?: string): Promise<any>;
|
|
697
805
|
acquireLock(documentId: string, auditMessage?: string): Promise<any>;
|
|
698
806
|
releaseLock(documentId: string, auditMessage?: string): Promise<any>;
|
|
807
|
+
/**
|
|
808
|
+
* Hold the document's lock for the length of `fn`, renewing it while `fn`
|
|
809
|
+
* runs and releasing it on the way out. Rejects with a 423 if another user
|
|
810
|
+
* holds it, and with DocumentLockLost if a renewal fails.
|
|
811
|
+
*/
|
|
812
|
+
locked<T>(
|
|
813
|
+
documentId: string,
|
|
814
|
+
fn: (lock: DocumentLock) => T | Promise<T>,
|
|
815
|
+
options?: { keepAlive?: boolean },
|
|
816
|
+
): Promise<T>;
|
|
699
817
|
getMedia(documentId: string): Promise<ArrayBuffer>;
|
|
700
818
|
uploadMedia(
|
|
701
819
|
documentId: string,
|
|
@@ -953,6 +1071,7 @@ interface TokensBundle {
|
|
|
953
1071
|
): Promise<any>;
|
|
954
1072
|
bulkCreate(body: any[], auditMessage?: string): Promise<{ ids: string[] }>;
|
|
955
1073
|
bulkDelete(body: any[], auditMessage?: string): Promise<void>;
|
|
1074
|
+
bulkUpdate(body: any[], auditMessage?: string): Promise<{ count: number }>;
|
|
956
1075
|
split(tokenId: string, position: number, auditMessage?: string): Promise<any>;
|
|
957
1076
|
merge(
|
|
958
1077
|
tokenId: string,
|
|
@@ -1025,12 +1144,22 @@ export declare class PlaidClient {
|
|
|
1025
1144
|
/** Fired once on HTTP 401 (see PlaidClientOptions.onAuthError). */
|
|
1026
1145
|
onAuthError: ((error: Error) => void) | null;
|
|
1027
1146
|
|
|
1028
|
-
// Batch control methods
|
|
1147
|
+
// Batch control methods.
|
|
1148
|
+
//
|
|
1149
|
+
// Batch mode is one flag on the whole client, so it catches every write made
|
|
1150
|
+
// while it is open, including writes made by code that knows nothing about
|
|
1151
|
+
// the batch. Reads are never caught: every read method goes over the wire
|
|
1152
|
+
// while a batch is open and is answered from the state the batch has not
|
|
1153
|
+
// committed yet, so app chrome sharing a client with an importer keeps
|
|
1154
|
+
// working. Neither are the calls that signal something out of band rather
|
|
1155
|
+
// than write project data: stopping a service request, a service reporting
|
|
1156
|
+
// its progress or result, taking and dropping a document lock, and the admin
|
|
1157
|
+
// actions on the server itself.
|
|
1029
1158
|
beginBatch(): void;
|
|
1030
1159
|
submitBatch(): Promise<any[]>;
|
|
1031
1160
|
abortBatch(): void;
|
|
1032
1161
|
isBatchMode(): boolean;
|
|
1033
|
-
/** Run `fn` with a batch open, then submit atomically (or abort if `fn` throws). Resolves to the results array. */
|
|
1162
|
+
/** Run `fn` with a batch open, then submit atomically (or abort if `fn` throws). Resolves to the results array. Writes inside `fn` queue; reads go over the wire and see the pre-batch state. */
|
|
1034
1163
|
batched(fn: () => void | Promise<void>): Promise<any[]>;
|
|
1035
1164
|
|
|
1036
1165
|
// Strict mode methods
|
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
|
+
}
|
package/src/http.js
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
import { transformRequest, transformResponse } from "./transforms.js";
|
|
2
2
|
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Batch mode: which calls join a batch, and which go over the wire anyway.
|
|
5
|
+
//
|
|
6
|
+
// `client.batched()` sets ONE flag on the whole client, and every call made
|
|
7
|
+
// while it is set is queued, whatever code made it. A browser client is shared
|
|
8
|
+
// by an editor, an importer and the app's chrome at once, so the call that
|
|
9
|
+
// lands inside someone else's batch is usually not the one that opened it.
|
|
10
|
+
// Every call reaching this layer is one of three things, and the third column
|
|
11
|
+
// is what a new endpoint has to pick.
|
|
12
|
+
//
|
|
13
|
+
// 1. A WRITE of project data: an entity, its metadata, a layer, a membership,
|
|
14
|
+
// a comment. This is what a batch is for, so it QUEUES. It is the default
|
|
15
|
+
// and needs no flag.
|
|
16
|
+
//
|
|
17
|
+
// 2. A READ. Pass `bypassBatch`. A queued read is broken three ways: the
|
|
18
|
+
// caller gets `{ batched: true }` instead of data, the queued GET takes a
|
|
19
|
+
// slot in the batch's results array and shifts every positional read after
|
|
20
|
+
// it, and server-side the sub-request runs against the batch's transaction
|
|
21
|
+
// connection rather than the pool, where `/query` throws and 500s the whole
|
|
22
|
+
// batch, rolling back every write in it. Bypassed, the read is answered
|
|
23
|
+
// from the pool and sees exactly the state it would have seen had the batch
|
|
24
|
+
// not been open.
|
|
25
|
+
//
|
|
26
|
+
// 3. An OUT-OF-BAND SIGNAL: stopping a service request, reporting a service's
|
|
27
|
+
// progress or result, taking or dropping a document lock, an admin action
|
|
28
|
+
// on the server itself. It is shaped like a write but carries no project
|
|
29
|
+
// data, and its whole value is that it happens NOW. Queued, it happens at
|
|
30
|
+
// submit, or never if the batch aborts, while its caller reads success.
|
|
31
|
+
// Pass `bypassBatch` for these too.
|
|
32
|
+
//
|
|
33
|
+
// A bypassed call neither joins the batch nor spends its stamp. Strict mode
|
|
34
|
+
// marks the batch's one expected document-version onto the first QUEUED write,
|
|
35
|
+
// and a call that went over the wire on its own is not that write. It does not
|
|
36
|
+
// join an open logical operation either: the audit group is a label for the
|
|
37
|
+
// writes, and a signal that is never audited would mark the group written and
|
|
38
|
+
// leave the relabel PATCH 404ing on a group nothing ever created.
|
|
39
|
+
//
|
|
40
|
+
// `noBatch` is not part of that judgment. It marks the five calls the batch
|
|
41
|
+
// transport cannot carry at all (a batch inside a batch, the multipart media
|
|
42
|
+
// and avatar uploads, the user-data store's put and delete) and raises so the
|
|
43
|
+
// caller finds out. Never put it on a read: it turns a swallowed read into a
|
|
44
|
+
// thrown one, which is what the chrome hit when an unrelated import was
|
|
45
|
+
// running. A blobless DELETE beside an upload is not one of them: it carries
|
|
46
|
+
// nothing the transport cannot express, so it takes its class from the three
|
|
47
|
+
// above like anything else.
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
3
50
|
// Default per-request timeout (ms). Applied to every request unless the client
|
|
4
51
|
// is constructed with a different `timeout` (0 / null disables it). Note: this
|
|
5
52
|
// also bounds media up/downloads — bump it (or disable) for very large files.
|
|
@@ -253,14 +300,12 @@ export function xhrSend(
|
|
|
253
300
|
* rawBody - Body value passed directly (no transform). Mutually exclusive with body.
|
|
254
301
|
* formData - If true, body is FormData; skip Content-Type header
|
|
255
302
|
* queryParams - Object of query param key/values to append
|
|
256
|
-
* noBatch - If true, throw when in batch mode
|
|
303
|
+
* noBatch - If true, throw when in batch mode. Only for calls the
|
|
304
|
+
* batch transport cannot carry at all (see the note at the
|
|
305
|
+
* top of this file); never for a read.
|
|
257
306
|
* bypassBatch - If true, go over the wire even while a batch is open.
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
* the sub-request runs against the tx Connection rather
|
|
261
|
-
* than the pool. A read swallowed by an ambient batch is
|
|
262
|
-
* therefore useless to the caller AND can fail the whole
|
|
263
|
-
* batch (see `query`).
|
|
307
|
+
* Every read carries it, and so does every out-of-band
|
|
308
|
+
* signal (see the note at the top of this file).
|
|
264
309
|
* skipResponseTransform - Return raw parsed JSON (no transformResponse)
|
|
265
310
|
* noAuth - Skip Authorization header
|
|
266
311
|
* binaryResponse - Return arrayBuffer instead of JSON/text
|
|
@@ -289,6 +334,20 @@ export async function makeRequest(client, method, path, options = {}) {
|
|
|
289
334
|
onUploadProgress,
|
|
290
335
|
} = options;
|
|
291
336
|
|
|
337
|
+
// A write must not go out on a lock that lapsed. `documents.locked()`
|
|
338
|
+
// records the loss here when its keep-alive cannot renew, and from that
|
|
339
|
+
// moment the block is holding nothing: an edit by somebody else can already
|
|
340
|
+
// have landed between the read the work was planned from and the write about
|
|
341
|
+
// to go out. Reads pass, and so do the lock routes themselves, which is how
|
|
342
|
+
// the block still releases on its way out.
|
|
343
|
+
if (
|
|
344
|
+
client.documentLockLost &&
|
|
345
|
+
method !== "GET" &&
|
|
346
|
+
!path.endsWith("/lock")
|
|
347
|
+
) {
|
|
348
|
+
throw client.documentLockLost;
|
|
349
|
+
}
|
|
350
|
+
|
|
292
351
|
// Build URL
|
|
293
352
|
let url = `${client.baseUrl}${path}`;
|
|
294
353
|
|
|
@@ -318,7 +377,7 @@ export async function makeRequest(client, method, path, options = {}) {
|
|
|
318
377
|
}
|
|
319
378
|
|
|
320
379
|
// Strict mode: append document-version for non-GET requests.
|
|
321
|
-
// Inside a batch, stamp ONLY the first write: batches run atomically
|
|
380
|
+
// Inside a batch, stamp ONLY the first queued write: batches run atomically
|
|
322
381
|
// server-side, so a version check on the first op gives whole-batch OCC
|
|
323
382
|
// semantics, while stamping every op would 409 the second op against the
|
|
324
383
|
// version bump the first op itself caused (every queued op captures the
|
|
@@ -333,7 +392,11 @@ export async function makeRequest(client, method, path, options = {}) {
|
|
|
333
392
|
const docVersion = client.documentVersions[docId];
|
|
334
393
|
const separator = url.includes("?") ? "&" : "?";
|
|
335
394
|
url += `${separator}document-version=${encodeURIComponent(docVersion)}`;
|
|
336
|
-
|
|
395
|
+
// A bypassing call queues nothing, so it must not spend the batch's one
|
|
396
|
+
// stamp. `query` is a POST and lands here from app chrome while someone
|
|
397
|
+
// else's batch is open: marking the batch stamped there left the first
|
|
398
|
+
// real write unversioned and the batch unguarded.
|
|
399
|
+
if (client.isBatching && !bypassBatch) client.batchVersionStamped = true;
|
|
337
400
|
}
|
|
338
401
|
}
|
|
339
402
|
|
|
@@ -350,7 +413,17 @@ export async function makeRequest(client, method, path, options = {}) {
|
|
|
350
413
|
// Logical-operation group (see client.beginOperation): stamp every write
|
|
351
414
|
// with the group id; the message rides along too so the server can label
|
|
352
415
|
// the group lazily on whichever tagged write lands first.
|
|
353
|
-
|
|
416
|
+
//
|
|
417
|
+
// A bypassing call is not one of those writes. The out-of-band signals are
|
|
418
|
+
// shaped like a write and carry no project data (a lock taken or renewed, a
|
|
419
|
+
// cancelled service request, a service reporting its progress, an admin
|
|
420
|
+
// control), and `query` is a read that travels as a POST. None of them lands
|
|
421
|
+
// in the audit log, so a stamp does nothing server-side while `written`
|
|
422
|
+
// promises a group that will never exist: the relabel PATCH then 404s. Same
|
|
423
|
+
// rule as the batch and the OCC stamp above, for the same reason: a
|
|
424
|
+
// bypassing call belongs to whoever made it, not to whatever operation
|
|
425
|
+
// happens to be open on this shared client.
|
|
426
|
+
if (client.operationGroup && method !== "GET" && !bypassBatch) {
|
|
354
427
|
const group = client.operationGroup;
|
|
355
428
|
const separator = url.includes("?") ? "&" : "?";
|
|
356
429
|
url += `${separator}group-id=${encodeURIComponent(group.id)}`;
|
|
@@ -359,10 +432,10 @@ export async function makeRequest(client, method, path, options = {}) {
|
|
|
359
432
|
group.written = true;
|
|
360
433
|
}
|
|
361
434
|
|
|
362
|
-
// Batch mode. A `bypassBatch`
|
|
363
|
-
// whoever
|
|
364
|
-
// client (a document reconcile, an import)
|
|
365
|
-
//
|
|
435
|
+
// Batch mode. A `bypassBatch` call is deliberately NOT queued: it belongs to
|
|
436
|
+
// whoever made it, not to whatever batch happens to be open on this shared
|
|
437
|
+
// client (a document reconcile, an import). See the three classes at the top
|
|
438
|
+
// of this file.
|
|
366
439
|
if (client.isBatching && !bypassBatch) {
|
|
367
440
|
if (noBatch) {
|
|
368
441
|
throw new Error(`This endpoint cannot be used in batch mode: ${path}`);
|