js-bao-wss-client 2.0.6 → 2.0.7
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/README.md +1 -1
- package/dist/JsBaoClient.d.ts +71 -47
- package/dist/JsBaoClient.d.ts.map +1 -1
- package/dist/JsBaoClient.js +93 -10
- package/dist/JsBaoClient.js.map +1 -1
- package/dist/api/cacheFacade.d.ts +6 -6
- package/dist/api/cacheFacade.d.ts.map +1 -1
- package/dist/api/cacheFacade.js.map +1 -1
- package/dist/api/collectionTypeConfigsApi.d.ts +23 -0
- package/dist/api/collectionTypeConfigsApi.d.ts.map +1 -1
- package/dist/api/collectionTypeConfigsApi.js.map +1 -1
- package/dist/api/collectionsApi.d.ts +15 -0
- package/dist/api/collectionsApi.d.ts.map +1 -1
- package/dist/api/collectionsApi.js.map +1 -1
- package/dist/api/cronTriggersApi.d.ts +4 -4
- package/dist/api/cronTriggersApi.d.ts.map +1 -1
- package/dist/api/databaseTypeConfigsApi.d.ts +104 -1
- package/dist/api/databaseTypeConfigsApi.d.ts.map +1 -1
- package/dist/api/databaseTypeConfigsApi.js.map +1 -1
- package/dist/api/databasesApi.d.ts +63 -22
- package/dist/api/databasesApi.d.ts.map +1 -1
- package/dist/api/databasesApi.js +22 -2
- package/dist/api/databasesApi.js.map +1 -1
- package/dist/api/documentsApi.d.ts +16 -4
- package/dist/api/documentsApi.d.ts.map +1 -1
- package/dist/api/documentsApi.js +16 -0
- package/dist/api/documentsApi.js.map +1 -1
- package/dist/api/geminiApi.d.ts +9 -9
- package/dist/api/geminiApi.d.ts.map +1 -1
- package/dist/api/geminiApi.js.map +1 -1
- package/dist/api/invitationsApi.d.ts +15 -3
- package/dist/api/invitationsApi.d.ts.map +1 -1
- package/dist/api/invitationsApi.js +15 -3
- package/dist/api/invitationsApi.js.map +1 -1
- package/dist/api/llmApi.d.ts +8 -8
- package/dist/api/llmApi.d.ts.map +1 -1
- package/dist/api/llmApi.js.map +1 -1
- package/dist/api/notificationsApi.d.ts +2 -2
- package/dist/api/ruleSetsApi.d.ts +57 -11
- package/dist/api/ruleSetsApi.d.ts.map +1 -1
- package/dist/api/ruleSetsApi.js.map +1 -1
- package/dist/browser.umd.js +278 -36
- package/dist/browser.umd.js.map +1 -1
- package/dist/internal/blobManager.d.ts +5 -5
- package/dist/internal/blobManager.d.ts.map +1 -1
- package/dist/internal/blobManager.js.map +1 -1
- package/dist/internal/documentManager.d.ts +1 -0
- package/dist/internal/documentManager.d.ts.map +1 -1
- package/dist/internal/documentManager.js.map +1 -1
- package/dist/internal/httpClient.d.ts +58 -1
- package/dist/internal/httpClient.d.ts.map +1 -1
- package/dist/internal/httpClient.js +132 -21
- package/dist/internal/httpClient.js.map +1 -1
- package/package.json +1 -1
package/dist/browser.umd.js
CHANGED
|
@@ -5689,10 +5689,22 @@
|
|
|
5689
5689
|
return result.data;
|
|
5690
5690
|
}
|
|
5691
5691
|
async requestRaw(method, path, data, options) {
|
|
5692
|
-
|
|
5693
|
-
|
|
5692
|
+
// Establish the single apply-lifecycle deadline HERE, before the proactive
|
|
5693
|
+
// `refreshIfExpiring()` preflight — not inside `fetchWithRefresh()`. The
|
|
5694
|
+
// preflight can itself fire a token refresh (when the access token is
|
|
5695
|
+
// within 120s of expiry), and a hung refresh endpoint at that point would
|
|
5696
|
+
// otherwise hang the whole call before any timeout budget existed. Passing
|
|
5697
|
+
// the deadline into both the preflight and `fetchWithRefresh` keeps the
|
|
5698
|
+
// WHOLE call (proactive refresh + request + reactive refresh + retry)
|
|
5699
|
+
// bounded by one `timeoutMs`. Non-apply callers omit `timeoutMs`, so
|
|
5700
|
+
// `deadline` is undefined and every step stays untimed exactly as before.
|
|
5701
|
+
const timeoutMs = options?.timeoutMs;
|
|
5702
|
+
const deadline = typeof timeoutMs === "number" && timeoutMs > 0
|
|
5703
|
+
? Date.now() + timeoutMs
|
|
5704
|
+
: undefined;
|
|
5705
|
+
await this.refreshIfExpiring(deadline);
|
|
5706
|
+
const { response, text } = await this.fetchWithRefresh(method, path, data, options, deadline, timeoutMs);
|
|
5694
5707
|
const contentType = response.headers.get("content-type") || "";
|
|
5695
|
-
const text = await response.text();
|
|
5696
5708
|
let parsed = text;
|
|
5697
5709
|
if (contentType.includes("application/json")) {
|
|
5698
5710
|
if (text) {
|
|
@@ -5715,21 +5727,40 @@
|
|
|
5715
5727
|
text,
|
|
5716
5728
|
};
|
|
5717
5729
|
}
|
|
5718
|
-
|
|
5730
|
+
/**
|
|
5731
|
+
* Refresh the access token via the refresh endpoint.
|
|
5732
|
+
*
|
|
5733
|
+
* `timeoutMs` bounds the refresh fetch with an `AbortController`, using the
|
|
5734
|
+
* same timeout path as normal requests (`fetchOrThrow`). It is passed by the
|
|
5735
|
+
* apply-lifecycle path so the whole call (original attempt + refresh + retry)
|
|
5736
|
+
* stays within the caller's request budget — a hung refresh endpoint would
|
|
5737
|
+
* otherwise bypass that budget and hang indefinitely. Omit it (the default)
|
|
5738
|
+
* to leave the refresh untimed, which is how the background
|
|
5739
|
+
* `refreshIfExpiring` and the public `refreshAccessToken` callers use it.
|
|
5740
|
+
*
|
|
5741
|
+
* On an aborted (timed-out) or otherwise failed refresh fetch this returns
|
|
5742
|
+
* `"network"`, so callers surface a retryable `JsBaoNetworkError`.
|
|
5743
|
+
*/
|
|
5744
|
+
async tryRefreshAccessToken(timeoutMs) {
|
|
5719
5745
|
try {
|
|
5720
5746
|
const { url, headers } = this.buildRefreshRequestConfig();
|
|
5721
|
-
const res = await
|
|
5747
|
+
const { response: res, text } = await this.fetchOrThrow(url, {
|
|
5722
5748
|
method: "POST",
|
|
5723
5749
|
credentials: "include",
|
|
5724
5750
|
headers,
|
|
5725
|
-
});
|
|
5751
|
+
}, timeoutMs);
|
|
5726
5752
|
if (res.status === 401) {
|
|
5727
5753
|
return "invalid";
|
|
5728
5754
|
}
|
|
5729
5755
|
if (!res.ok) {
|
|
5730
5756
|
return "network";
|
|
5731
5757
|
}
|
|
5732
|
-
|
|
5758
|
+
// `fetchOrThrow` already read the body under the same abort timer, so a
|
|
5759
|
+
// refresh response that stalled mid-body was aborted (surfacing as a
|
|
5760
|
+
// rejected fetch → outer catch → "network") rather than hanging here.
|
|
5761
|
+
// A malformed body throws from JSON.parse → outer catch → "network",
|
|
5762
|
+
// matching the prior `res.json()` behavior.
|
|
5763
|
+
const data = text ? JSON.parse(text) : null;
|
|
5733
5764
|
const newToken = data?.token;
|
|
5734
5765
|
if (!newToken)
|
|
5735
5766
|
return "invalid";
|
|
@@ -5740,14 +5771,36 @@
|
|
|
5740
5771
|
return "network";
|
|
5741
5772
|
}
|
|
5742
5773
|
}
|
|
5743
|
-
|
|
5774
|
+
/**
|
|
5775
|
+
* Proactively refresh the access token when it is within 120s of expiry.
|
|
5776
|
+
*
|
|
5777
|
+
* When the caller opted into an apply-lifecycle deadline, thread the
|
|
5778
|
+
* REMAINING budget into the refresh fetch so a hung refresh endpoint at this
|
|
5779
|
+
* preflight point is aborted within the same `timeoutMs` (rather than hanging
|
|
5780
|
+
* before the request path's budget exists). This is best-effort: if the
|
|
5781
|
+
* budget is already spent we simply skip the proactive refresh and let the
|
|
5782
|
+
* request path surface the exhausted-budget network error. Untimed callers
|
|
5783
|
+
* (deadline undefined) keep the original behavior — an unbounded proactive
|
|
5784
|
+
* refresh.
|
|
5785
|
+
*/
|
|
5786
|
+
async refreshIfExpiring(deadline) {
|
|
5744
5787
|
try {
|
|
5745
5788
|
const token = this.config.getToken();
|
|
5746
5789
|
if (!token)
|
|
5747
5790
|
return;
|
|
5748
5791
|
const exp = this.getTokenExpiry(token);
|
|
5749
5792
|
if (exp && exp - Date.now() / 1000 < 120) {
|
|
5750
|
-
|
|
5793
|
+
let budget;
|
|
5794
|
+
if (deadline !== undefined) {
|
|
5795
|
+
const remaining = deadline - Date.now();
|
|
5796
|
+
// No time left for a best-effort proactive refresh — skip it; the
|
|
5797
|
+
// request path's `remainingBudgetOrThrow` raises the retryable
|
|
5798
|
+
// exhausted-budget error.
|
|
5799
|
+
if (remaining <= 0)
|
|
5800
|
+
return;
|
|
5801
|
+
budget = remaining;
|
|
5802
|
+
}
|
|
5803
|
+
const outcome = await this.tryRefreshAccessToken(budget);
|
|
5751
5804
|
this.config.onRefreshOutcome(outcome);
|
|
5752
5805
|
}
|
|
5753
5806
|
}
|
|
@@ -5869,16 +5922,40 @@
|
|
|
5869
5922
|
}
|
|
5870
5923
|
return { url: url.toString(), init };
|
|
5871
5924
|
}
|
|
5872
|
-
|
|
5925
|
+
/**
|
|
5926
|
+
* Return the remaining apply-lifecycle budget for the next sub-step, or throw
|
|
5927
|
+
* a retryable `JsBaoNetworkError` if the shared deadline is already spent.
|
|
5928
|
+
* Threading the *remaining* time into each sub-step keeps every step
|
|
5929
|
+
* (request, token refresh, retry) bounded by the caller's single `timeoutMs`.
|
|
5930
|
+
* Non-timed requests (deadline undefined) return undefined — every step stays
|
|
5931
|
+
* untimed exactly as before.
|
|
5932
|
+
*/
|
|
5933
|
+
remainingBudgetOrThrow(deadline, timeoutMs, url) {
|
|
5934
|
+
if (deadline === undefined)
|
|
5935
|
+
return undefined;
|
|
5936
|
+
const remaining = deadline - Date.now();
|
|
5937
|
+
if (remaining <= 0) {
|
|
5938
|
+
throw new JsBaoNetworkError(`Network request to ${url} timed out: apply-lifecycle budget of ${timeoutMs}ms exhausted`);
|
|
5939
|
+
}
|
|
5940
|
+
return remaining;
|
|
5941
|
+
}
|
|
5942
|
+
async fetchWithRefresh(method, path, data, options, deadline, timeoutMs) {
|
|
5943
|
+
// The whole apply-lifecycle call (original attempt + a possible token
|
|
5944
|
+
// refresh + the retry after refresh) shares ONE budget via the absolute
|
|
5945
|
+
// `deadline` established in `requestRaw`. `remainingBudgetOrThrow` throws a
|
|
5946
|
+
// retryable network error if the budget is already spent, so we never start
|
|
5947
|
+
// a sub-step (including the refresh) with no time left.
|
|
5873
5948
|
let requestConfig = this.buildRequestInit(method, path, data, options);
|
|
5874
5949
|
this.logger.debug(`Making ${method} request to ${requestConfig.url}`);
|
|
5875
|
-
let
|
|
5876
|
-
if (response.status === 401) {
|
|
5877
|
-
|
|
5950
|
+
let result = await this.fetchOrThrow(requestConfig.url, requestConfig.init, this.remainingBudgetOrThrow(deadline, timeoutMs, requestConfig.url));
|
|
5951
|
+
if (result.response.status === 401) {
|
|
5952
|
+
// Bound the refresh with whatever time is left in the shared budget
|
|
5953
|
+
// (throws if already exhausted, rather than starting an unbounded refresh).
|
|
5954
|
+
const outcome = await this.tryRefreshAccessToken(this.remainingBudgetOrThrow(deadline, timeoutMs, requestConfig.url));
|
|
5878
5955
|
this.config.onRefreshOutcome(outcome);
|
|
5879
5956
|
if (outcome === "success") {
|
|
5880
5957
|
requestConfig = this.buildRequestInit(method, path, data, options);
|
|
5881
|
-
|
|
5958
|
+
result = await this.fetchOrThrow(requestConfig.url, requestConfig.init, this.remainingBudgetOrThrow(deadline, timeoutMs, requestConfig.url));
|
|
5882
5959
|
}
|
|
5883
5960
|
else if (outcome === "invalid") {
|
|
5884
5961
|
throw new JsBaoApiError(401, "Invalid credentials");
|
|
@@ -5892,17 +5969,51 @@
|
|
|
5892
5969
|
throw new JsBaoNetworkError("Token refresh failed due to a network error");
|
|
5893
5970
|
}
|
|
5894
5971
|
}
|
|
5895
|
-
return
|
|
5972
|
+
return result;
|
|
5896
5973
|
}
|
|
5897
|
-
|
|
5974
|
+
/**
|
|
5975
|
+
* Fetch `url`, then read the full response body — both under one
|
|
5976
|
+
* `AbortController`/timeout. The timer is NOT cleared when `fetch()` resolves:
|
|
5977
|
+
* a resolved `fetch` only means the response HEADERS arrived. A server that
|
|
5978
|
+
* sends headers and then stalls the body would leave the body read
|
|
5979
|
+
* (`response.text()`) hanging indefinitely, stranding an apply run even though
|
|
5980
|
+
* the request "succeeded". Keeping the timer active through body consumption
|
|
5981
|
+
* lets the timeout abort a stalled body read too; on timeout the aborted read
|
|
5982
|
+
* rejects and we surface the same retryable `JsBaoNetworkError` as any other
|
|
5983
|
+
* transport failure. The timer is cleared in `finally`, after the body is
|
|
5984
|
+
* fully read (or the read/fetch rejects), so it never leaks.
|
|
5985
|
+
*/
|
|
5986
|
+
async fetchOrThrow(url, init, timeoutMs) {
|
|
5987
|
+
let controller;
|
|
5988
|
+
let timer;
|
|
5989
|
+
let fetchInit = init;
|
|
5990
|
+
if (typeof timeoutMs === "number" &&
|
|
5991
|
+
timeoutMs > 0 &&
|
|
5992
|
+
typeof AbortController !== "undefined") {
|
|
5993
|
+
controller = new AbortController();
|
|
5994
|
+
fetchInit = { ...init, signal: controller.signal };
|
|
5995
|
+
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
5996
|
+
}
|
|
5898
5997
|
try {
|
|
5899
|
-
|
|
5998
|
+
const response = await fetch(url, fetchInit);
|
|
5999
|
+
const text = await response.text();
|
|
6000
|
+
return { response, text };
|
|
5900
6001
|
}
|
|
5901
6002
|
catch (err) {
|
|
5902
|
-
// fetch
|
|
5903
|
-
// host, refused connection, DNS failure,
|
|
5904
|
-
//
|
|
5905
|
-
|
|
6003
|
+
// fetch — OR the body read — rejected: the server was never reached
|
|
6004
|
+
// (unreachable host, refused connection, DNS failure), or the request /
|
|
6005
|
+
// body read outran its timeout and was aborted. Surface a typed,
|
|
6006
|
+
// retryable network error instead of the raw TypeError/AbortError so
|
|
6007
|
+
// callers can retry.
|
|
6008
|
+
const timedOut = controller?.signal.aborted === true;
|
|
6009
|
+
throw new JsBaoNetworkError(timedOut
|
|
6010
|
+
? `Network request to ${url} timed out after ${timeoutMs}ms`
|
|
6011
|
+
: `Network request to ${url} failed`, err);
|
|
6012
|
+
}
|
|
6013
|
+
finally {
|
|
6014
|
+
if (timer) {
|
|
6015
|
+
clearTimeout(timer);
|
|
6016
|
+
}
|
|
5906
6017
|
}
|
|
5907
6018
|
}
|
|
5908
6019
|
}
|
|
@@ -10590,6 +10701,18 @@
|
|
|
10590
10701
|
}
|
|
10591
10702
|
return this.client.makeRequest("PUT", `/documents/${documentId}/link-access`, { level });
|
|
10592
10703
|
}
|
|
10704
|
+
/**
|
|
10705
|
+
* Read the current "anyone with the link" access state for a document.
|
|
10706
|
+
* Authorized for any caller who can read the document OR manage its sharing
|
|
10707
|
+
* (document owner, app owner, or read-write editor) — so an app owner can
|
|
10708
|
+
* inspect the state even without a content grant. Returns
|
|
10709
|
+
* `{ documentId, linkAccess: null }` when link access is off.
|
|
10710
|
+
*
|
|
10711
|
+
* @param documentId - The document to read link access for.
|
|
10712
|
+
*/
|
|
10713
|
+
async getLinkAccess(documentId) {
|
|
10714
|
+
return this.client.makeRequest("GET", `/documents/${documentId}/link-access`);
|
|
10715
|
+
}
|
|
10593
10716
|
/**
|
|
10594
10717
|
* Turn off "anyone with the link" access for a document. Existing explicit
|
|
10595
10718
|
* grants are unaffected; link-only viewers lose access.
|
|
@@ -11314,6 +11437,10 @@
|
|
|
11314
11437
|
async setLinkAccess(level) {
|
|
11315
11438
|
return this.docs.setLinkAccess(this.documentId, level);
|
|
11316
11439
|
}
|
|
11440
|
+
/** Read the current "anyone with the link" access state for this document. */
|
|
11441
|
+
async getLinkAccess() {
|
|
11442
|
+
return this.docs.getLinkAccess(this.documentId);
|
|
11443
|
+
}
|
|
11317
11444
|
/** Turn off "anyone with the link" access for this document. */
|
|
11318
11445
|
async clearLinkAccess() {
|
|
11319
11446
|
return this.docs.clearLinkAccess(this.documentId);
|
|
@@ -12384,6 +12511,11 @@
|
|
|
12384
12511
|
* The returned payload includes the same dict under both `metadata` (legacy
|
|
12385
12512
|
* wire name) and `celContext` (new user-facing name).
|
|
12386
12513
|
*
|
|
12514
|
+
* @deprecated Prefer resource metadata categories (issue #1420). The
|
|
12515
|
+
* `metadataAccess` gate that controls this read uses one CEL expression to
|
|
12516
|
+
* gate read AND update, so granting read also grants update. A metadata
|
|
12517
|
+
* category has separate `readRule`/`writeRule`; read its values from CEL as
|
|
12518
|
+
* `md.self.<category>.<key>`. See the migration recipe in `databases.md`.
|
|
12387
12519
|
* @param databaseId - The unique identifier of the database to read
|
|
12388
12520
|
*/
|
|
12389
12521
|
async getCelContext(databaseId) {
|
|
@@ -12396,6 +12528,12 @@
|
|
|
12396
12528
|
* `database.celContext.<key>` (or legacy `database.metadata.<key>`) and
|
|
12397
12529
|
* from filter JSON as `$database.celContext.<key>`.
|
|
12398
12530
|
*
|
|
12531
|
+
* @deprecated Prefer resource metadata categories (issue #1420). Writing here
|
|
12532
|
+
* is gated by the same single `metadataAccess` expression that gates reads, so
|
|
12533
|
+
* anyone allowed to read can also update. A metadata category has separate
|
|
12534
|
+
* `readRule`/`writeRule`; write its values with `resourceMetadata` /
|
|
12535
|
+
* `initialMetadata` and read them from CEL as `md.self.<category>.<key>`. See
|
|
12536
|
+
* the migration recipe in `databases.md`.
|
|
12399
12537
|
* @param databaseId - The unique identifier of the database to update
|
|
12400
12538
|
* @param celContext - Key-value pairs to merge into the database's existing CEL context
|
|
12401
12539
|
*/
|
|
@@ -12404,7 +12542,11 @@
|
|
|
12404
12542
|
}
|
|
12405
12543
|
/**
|
|
12406
12544
|
* Read a database's CEL context dict.
|
|
12407
|
-
* @deprecated
|
|
12545
|
+
* @deprecated Prefer resource metadata categories (issue #1420) — a category
|
|
12546
|
+
* has separate `readRule`/`writeRule` (read no longer implies update) and is
|
|
12547
|
+
* read from CEL as `md.self.<category>.<key>`. See the migration recipe in
|
|
12548
|
+
* `databases.md`. This is the legacy wire-name alias of the (also-deprecated)
|
|
12549
|
+
* {@link getCelContext}.
|
|
12408
12550
|
* @param databaseId - The unique identifier of the database to read
|
|
12409
12551
|
*/
|
|
12410
12552
|
async getMetadata(databaseId) {
|
|
@@ -12412,7 +12554,12 @@
|
|
|
12412
12554
|
}
|
|
12413
12555
|
/**
|
|
12414
12556
|
* Update a database's CEL context dict (merge with existing).
|
|
12415
|
-
* @deprecated
|
|
12557
|
+
* @deprecated Prefer resource metadata categories (issue #1420) — a category
|
|
12558
|
+
* has separate `readRule`/`writeRule`, so a writer no longer inherits update
|
|
12559
|
+
* from read access. Write category values with `resourceMetadata` /
|
|
12560
|
+
* `initialMetadata` and read them from CEL as `md.self.<category>.<key>`. See
|
|
12561
|
+
* the migration recipe in `databases.md`. This is the legacy wire-name alias
|
|
12562
|
+
* of the (also-deprecated) {@link updateCelContext}.
|
|
12416
12563
|
* @param databaseId - The unique identifier of the database to update
|
|
12417
12564
|
* @param metadata - Key-value pairs to merge into the database's existing CEL context
|
|
12418
12565
|
*/
|
|
@@ -13541,7 +13688,14 @@
|
|
|
13541
13688
|
return this.client.makeRequest("POST", "/invitations", params);
|
|
13542
13689
|
}
|
|
13543
13690
|
/**
|
|
13544
|
-
* List app-level invitations
|
|
13691
|
+
* List app-level invitations.
|
|
13692
|
+
*
|
|
13693
|
+
* Admins and owners see the full app list. Members see only the
|
|
13694
|
+
* invitations they created themselves — the `memberInvitationsEnabled`
|
|
13695
|
+
* flag gates *creating* invitations, not listing your own (a member can
|
|
13696
|
+
* always see and revoke invitations they already sent). A member with no
|
|
13697
|
+
* invitations of their own gets an empty list, not a 403.
|
|
13698
|
+
*
|
|
13545
13699
|
* @param options - Pagination options
|
|
13546
13700
|
*/
|
|
13547
13701
|
async list(options) {
|
|
@@ -13555,8 +13709,13 @@
|
|
|
13555
13709
|
return this.client.makeRequest("GET", path);
|
|
13556
13710
|
}
|
|
13557
13711
|
/**
|
|
13558
|
-
* Delete an app-level invitation
|
|
13559
|
-
*
|
|
13712
|
+
* Delete (revoke) an app-level invitation.
|
|
13713
|
+
*
|
|
13714
|
+
* Admins and owners can revoke any invitation. A member can revoke only
|
|
13715
|
+
* invitations they created themselves (403 otherwise). Revoking an active
|
|
13716
|
+
* invitation frees the member's quota. Also cascade-deletes any linked
|
|
13717
|
+
* deferred grants.
|
|
13718
|
+
*
|
|
13560
13719
|
* @param invitationId - The invitation to delete
|
|
13561
13720
|
*/
|
|
13562
13721
|
async delete(invitationId) {
|
|
@@ -13687,6 +13846,24 @@
|
|
|
13687
13846
|
const DEFAULT_RETURN_ACTIVE_MIN_MS = 5 * 60 * 1000;
|
|
13688
13847
|
const DEFAULT_SYNC_ERROR_MIN_MS = 30 * 1000;
|
|
13689
13848
|
const logger = createLogger({ scope: "JsBaoClient" });
|
|
13849
|
+
// Workflow apply-lifecycle durability (#1694). The server confirm is idempotent
|
|
13850
|
+
// (read-then-conditional-write; returns `confirmed: true` if the run already
|
|
13851
|
+
// completed), so a lost or hung confirm POST is safe to retry — retrying only
|
|
13852
|
+
// re-sends the confirm, never the user's onApply handler. These bounds keep the
|
|
13853
|
+
// retry inside the server's 30s apply lease so a stranded run recovers within
|
|
13854
|
+
// the same client session instead of waiting for a full restart.
|
|
13855
|
+
//
|
|
13856
|
+
// A per-request timeout (via AbortController in the HTTP layer) turns a hung
|
|
13857
|
+
// apply POST into a retryable network error. Scope note: this timeout is applied
|
|
13858
|
+
// only to the apply-lifecycle calls (claim/confirm/release), not to every client
|
|
13859
|
+
// request — a blanket request timeout has a much larger blast radius.
|
|
13860
|
+
const APPLY_REQUEST_TIMEOUT_MS = 8000;
|
|
13861
|
+
const APPLY_CONFIRM_MAX_ATTEMPTS = 6;
|
|
13862
|
+
const APPLY_CONFIRM_BASE_DELAY_MS = 250;
|
|
13863
|
+
const APPLY_CONFIRM_MAX_DELAY_MS = 4000;
|
|
13864
|
+
// Total retry budget, kept under the 30s server apply lease so the claim is
|
|
13865
|
+
// still valid when a retry finally lands.
|
|
13866
|
+
const APPLY_CONFIRM_RETRY_BUDGET_MS = 25000;
|
|
13690
13867
|
function debugLog(tag, payload) {
|
|
13691
13868
|
try {
|
|
13692
13869
|
if (!logger.shouldLog("debug"))
|
|
@@ -16268,7 +16445,9 @@
|
|
|
16268
16445
|
if (options.contextDocId) {
|
|
16269
16446
|
payload.contextDocId = options.contextDocId;
|
|
16270
16447
|
}
|
|
16271
|
-
const response = await this.makeRequest("POST", path, payload
|
|
16448
|
+
const response = await this.makeRequest("POST", path, payload, {
|
|
16449
|
+
timeoutMs: APPLY_REQUEST_TIMEOUT_MS,
|
|
16450
|
+
});
|
|
16272
16451
|
return response;
|
|
16273
16452
|
}
|
|
16274
16453
|
async releaseWorkflowApply(options) {
|
|
@@ -16279,7 +16458,9 @@
|
|
|
16279
16458
|
if (options.contextDocId) {
|
|
16280
16459
|
payload.contextDocId = options.contextDocId;
|
|
16281
16460
|
}
|
|
16282
|
-
const response = await this.makeRequest("POST", path, payload
|
|
16461
|
+
const response = await this.makeRequest("POST", path, payload, {
|
|
16462
|
+
timeoutMs: APPLY_REQUEST_TIMEOUT_MS,
|
|
16463
|
+
});
|
|
16283
16464
|
return response;
|
|
16284
16465
|
}
|
|
16285
16466
|
async confirmWorkflowApply(options) {
|
|
@@ -16296,9 +16477,63 @@
|
|
|
16296
16477
|
if (options.contextDocId) {
|
|
16297
16478
|
payload.contextDocId = options.contextDocId;
|
|
16298
16479
|
}
|
|
16299
|
-
const response = await this.makeRequest("POST", path, payload
|
|
16480
|
+
const response = await this.makeRequest("POST", path, payload, {
|
|
16481
|
+
timeoutMs: APPLY_REQUEST_TIMEOUT_MS,
|
|
16482
|
+
});
|
|
16300
16483
|
return response;
|
|
16301
16484
|
}
|
|
16485
|
+
/**
|
|
16486
|
+
* Whether a thrown confirm-apply error is worth retrying. Network failures
|
|
16487
|
+
* (unreachable server, refused connection, DNS failure, or an aborted
|
|
16488
|
+
* timeout — all surfaced as `JsBaoNetworkError`) and HTTP 5xx are transient,
|
|
16489
|
+
* so retrying the idempotent confirm may still succeed. Any other HTTP status
|
|
16490
|
+
* (4xx) is a definitive rejection and is not retried.
|
|
16491
|
+
*/
|
|
16492
|
+
isRetryableConfirmError(err) {
|
|
16493
|
+
if (err instanceof JsBaoNetworkError) {
|
|
16494
|
+
return true;
|
|
16495
|
+
}
|
|
16496
|
+
if (err instanceof JsBaoApiError) {
|
|
16497
|
+
return err.status >= 500 && err.status < 600;
|
|
16498
|
+
}
|
|
16499
|
+
return false;
|
|
16500
|
+
}
|
|
16501
|
+
/**
|
|
16502
|
+
* Retry the confirm POST on transient failures, bounded by both an attempt
|
|
16503
|
+
* count and a time budget that stays within the server's apply lease. Only
|
|
16504
|
+
* the confirm POST is retried here — the caller runs the user's onApply
|
|
16505
|
+
* handler exactly once, before invoking this, so retries never re-run it.
|
|
16506
|
+
* A `confirmed: false` result (a genuine claim loss) is returned normally,
|
|
16507
|
+
* not retried; the caller releases the claim in that case.
|
|
16508
|
+
*/
|
|
16509
|
+
async confirmWorkflowApplyWithRetry(options) {
|
|
16510
|
+
const deadline = Date.now() + APPLY_CONFIRM_RETRY_BUDGET_MS;
|
|
16511
|
+
let attempt = 0;
|
|
16512
|
+
let delayMs = APPLY_CONFIRM_BASE_DELAY_MS;
|
|
16513
|
+
for (;;) {
|
|
16514
|
+
attempt++;
|
|
16515
|
+
try {
|
|
16516
|
+
return await this.confirmWorkflowApply(options);
|
|
16517
|
+
}
|
|
16518
|
+
catch (err) {
|
|
16519
|
+
const nextDelay = Math.min(delayMs, APPLY_CONFIRM_MAX_DELAY_MS);
|
|
16520
|
+
const outOfBudget = Date.now() + nextDelay >= deadline;
|
|
16521
|
+
if (!this.isRetryableConfirmError(err) ||
|
|
16522
|
+
attempt >= APPLY_CONFIRM_MAX_ATTEMPTS ||
|
|
16523
|
+
outOfBudget) {
|
|
16524
|
+
throw err;
|
|
16525
|
+
}
|
|
16526
|
+
logger.warn("[workflowApply] confirm failed, retrying", {
|
|
16527
|
+
workflowKey: options.workflowKey,
|
|
16528
|
+
runKey: options.runKey,
|
|
16529
|
+
attempt,
|
|
16530
|
+
error: err instanceof Error ? err.message : String(err),
|
|
16531
|
+
});
|
|
16532
|
+
await new Promise((resolve) => setTimeout(resolve, nextDelay));
|
|
16533
|
+
delayMs = Math.min(delayMs * 2, APPLY_CONFIRM_MAX_DELAY_MS);
|
|
16534
|
+
}
|
|
16535
|
+
}
|
|
16536
|
+
}
|
|
16302
16537
|
async getWorkflowPendingApplies(options) {
|
|
16303
16538
|
if (!options?.contextDocId) {
|
|
16304
16539
|
throw new JsBaoError("INVALID_ARGUMENT", "contextDocId is required for workflows.getPendingApplies");
|
|
@@ -16346,11 +16581,15 @@
|
|
|
16346
16581
|
startedByUserId: event.startedByUserId,
|
|
16347
16582
|
meta: statusResult.run?.meta ?? undefined,
|
|
16348
16583
|
});
|
|
16349
|
-
// Confirm the apply
|
|
16350
|
-
//
|
|
16351
|
-
//
|
|
16352
|
-
//
|
|
16353
|
-
|
|
16584
|
+
// Confirm the apply, retrying the POST on transient failures (#1694).
|
|
16585
|
+
// The handler above has already run exactly once; the retry re-sends only
|
|
16586
|
+
// the idempotent confirm, never the handler. A lost or hung confirm would
|
|
16587
|
+
// otherwise strand the run in `apply_claimed` for the whole client session
|
|
16588
|
+
// with no way to recover short of a restart. Surface a server-side
|
|
16589
|
+
// rejection (`confirmed: false`) as an error so we don't silently leave
|
|
16590
|
+
// the workflow stuck in `apply_claimed` (issue #614). The catch block
|
|
16591
|
+
// below releases the claim and emits a diagnostic.
|
|
16592
|
+
const confirmResult = await this.confirmWorkflowApplyWithRetry({
|
|
16354
16593
|
workflowKey: event.workflowKey,
|
|
16355
16594
|
runKey: event.runKey,
|
|
16356
16595
|
contextDocId: event.contextDocId,
|
|
@@ -16360,7 +16599,10 @@
|
|
|
16360
16599
|
}
|
|
16361
16600
|
}
|
|
16362
16601
|
catch (err) {
|
|
16363
|
-
|
|
16602
|
+
// Elevated to `error` (#1694): apply-flow failures strand a run in
|
|
16603
|
+
// `apply_claimed`, and tests (and apps) commonly run at logLevel `error`,
|
|
16604
|
+
// so a `warn`/`debug` diagnostic here is invisible exactly when it matters.
|
|
16605
|
+
logger.error("[workflowApply] apply handler failed", {
|
|
16364
16606
|
workflowKey: event.workflowKey,
|
|
16365
16607
|
runKey: event.runKey,
|
|
16366
16608
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -16374,7 +16616,7 @@
|
|
|
16374
16616
|
});
|
|
16375
16617
|
}
|
|
16376
16618
|
catch (releaseErr) {
|
|
16377
|
-
logger.
|
|
16619
|
+
logger.error("[workflowApply] failed to release claim", {
|
|
16378
16620
|
workflowKey: event.workflowKey,
|
|
16379
16621
|
runKey: event.runKey,
|
|
16380
16622
|
error: releaseErr instanceof Error ? releaseErr.message : String(releaseErr),
|