@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 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?: { prefix?: string; includeValues?: boolean },
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larc-iu/plaid-client",
3
- "version": "0.2.0-alpha.12",
3
+ "version": "0.2.0-alpha.14",
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
+ }
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
- * For READS only: a batch is a write transaction, its
259
- * ops return no value to their caller until submit, and
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
- if (client.isBatching) client.batchVersionStamped = true;
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
- if (client.operationGroup && method !== "GET") {
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` read is deliberately NOT queued: it belongs to
363
- // whoever called it, not to whatever batch happens to be open on this shared
364
- // client (a document reconcile, an import), and it reads the pre-batch state
365
- // exactly as it would have if the batch were not running.
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}`);