@forgezero/providers 0.1.4 → 0.1.5
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/dist/binance.js +1 -1
- package/dist/chain.js +1 -1
- package/dist/database.d.ts +100 -12
- package/dist/database.js +312 -35
- package/dist/email.js +1 -1
- package/dist/http.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/pool.js +1 -1
- package/dist/storage.js +1 -1
- package/dist/translation.js +1 -1
- package/package.json +1 -1
package/dist/binance.js
CHANGED
package/dist/chain.js
CHANGED
package/dist/database.d.ts
CHANGED
|
@@ -13,36 +13,124 @@
|
|
|
13
13
|
* HEALTH three failed connections mark it degraded, and the same admin
|
|
14
14
|
* screen that shows email relays shows the database
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* answering.
|
|
16
|
+
* Coordinator routing below remains inside one authenticated cluster. It never
|
|
17
|
+
* treats a different database or replica as an automatic write destination.
|
|
19
18
|
*/
|
|
20
19
|
/** Just enough of an ArangoDB client that this package imports nothing. */
|
|
21
20
|
export interface ArangoLike {
|
|
22
|
-
query<T = unknown>(query: unknown, bindVars?: Record<string, unknown
|
|
21
|
+
query<T = unknown>(query: unknown, bindVars?: Record<string, unknown>, options?: {
|
|
22
|
+
allowDirtyRead?: boolean;
|
|
23
|
+
}): Promise<{
|
|
23
24
|
all(): Promise<T[]>;
|
|
24
25
|
}>;
|
|
25
26
|
version?(): Promise<{
|
|
26
27
|
version: string;
|
|
27
28
|
}>;
|
|
29
|
+
close?(): void | Promise<void>;
|
|
28
30
|
}
|
|
29
|
-
export interface
|
|
31
|
+
export interface ArangoConnectOptions {
|
|
32
|
+
/** One coordinator from the verified list; the adapter owns selection. */
|
|
30
33
|
url: string;
|
|
31
34
|
database: string;
|
|
32
35
|
username: string;
|
|
36
|
+
password: string;
|
|
37
|
+
loadBalancingStrategy: 'NONE';
|
|
38
|
+
/** Always zero: only this adapter may decide whether replay is safe. */
|
|
39
|
+
maxRetries: number;
|
|
40
|
+
}
|
|
41
|
+
export interface ArangoConfig {
|
|
42
|
+
/** Backwards-compatible single-coordinator coordinate. */
|
|
43
|
+
url?: string;
|
|
44
|
+
/** Explicit authenticated private coordinators. Bounded to prevent config abuse. */
|
|
45
|
+
urls?: readonly string[];
|
|
46
|
+
/**
|
|
47
|
+
* Optional bootstrap Coordinators already proven to be in Arango `readonly`
|
|
48
|
+
* server mode. Prefer signed, probed membership for production.
|
|
49
|
+
*/
|
|
50
|
+
readOnlyUrls?: readonly string[];
|
|
51
|
+
/**
|
|
52
|
+
* Dynamic membership is accepted only as a signed/attested envelope verified
|
|
53
|
+
* by the host. A Worker/KV node list by itself is discovery data, not routing
|
|
54
|
+
* authority.
|
|
55
|
+
*/
|
|
56
|
+
membership?: ArangoMembershipSnapshot;
|
|
57
|
+
verifyMembership?: ArangoMembershipVerifier;
|
|
58
|
+
/** Required with dynamic membership, preventing a valid list crossing clusters. */
|
|
59
|
+
clusterId?: string;
|
|
60
|
+
database: string;
|
|
61
|
+
username: string;
|
|
33
62
|
/** Built by the host, because a connection pool must outlive one call. */
|
|
34
|
-
connect?: (options:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
63
|
+
connect?: (options: ArangoConnectOptions) => ArangoLike;
|
|
64
|
+
/** Maximum coordinator attempts for an explicitly safe request. */
|
|
65
|
+
maxCoordinatorAttempts?: number;
|
|
66
|
+
/** Local transport-failure quarantine. Primarily injectable for tests. */
|
|
67
|
+
unhealthyForMs?: number;
|
|
68
|
+
/** What an explicit read-only preference does when no read-only coordinator can serve it. */
|
|
69
|
+
readOnlyFallback?: 'authoritative' | 'error';
|
|
70
|
+
now?: () => number;
|
|
41
71
|
}
|
|
42
72
|
export interface Query {
|
|
43
73
|
query: unknown;
|
|
44
74
|
bindVars?: Record<string, unknown>;
|
|
75
|
+
/**
|
|
76
|
+
* No retry is the secure default. `read` and `idempotent` are explicit
|
|
77
|
+
* caller assertions; this adapter never guesses AQL semantics from text.
|
|
78
|
+
*/
|
|
79
|
+
retrySafety?: 'read' | 'idempotent' | 'never';
|
|
80
|
+
/**
|
|
81
|
+
* `prefer-readonly` targets Coordinators whose actual Arango server mode is
|
|
82
|
+
* `readonly`. It is valid only with `retrySafety: 'read'`; ForgeZero does not
|
|
83
|
+
* infer AQL semantics. Community 3.11.14 dirty follower reads are not exposed:
|
|
84
|
+
* that server feature is Enterprise-only.
|
|
85
|
+
*/
|
|
86
|
+
readPreference?: 'authoritative' | 'prefer-readonly';
|
|
87
|
+
/** Required when `retrySafety` is `idempotent`; retained for audit correlation. */
|
|
88
|
+
idempotencyKey?: string;
|
|
45
89
|
}
|
|
90
|
+
export interface ArangoCoordinatorMember {
|
|
91
|
+
url: string;
|
|
92
|
+
/** ArangoDB has no read-only DBServer/client role. Clients address Coordinators. */
|
|
93
|
+
role: 'coordinator';
|
|
94
|
+
/** Actual `/_admin/server/mode` state for this Coordinator. */
|
|
95
|
+
serverMode: 'default' | 'readonly';
|
|
96
|
+
/** ForgeZero request-routing capability, required to agree with `serverMode`. */
|
|
97
|
+
access: 'read-write' | 'read-only';
|
|
98
|
+
capabilities: {
|
|
99
|
+
/** Always false for the exact Community 3.11.14 contract. */
|
|
100
|
+
dirtyFollowerReads: false;
|
|
101
|
+
};
|
|
102
|
+
status: 'healthy' | 'degraded' | 'unhealthy';
|
|
103
|
+
/** Milliseconds since epoch, covered by the membership signature/attestation. */
|
|
104
|
+
observedAt: number;
|
|
105
|
+
/** Enrolled hybrid identity. The node key is its Ed25519 public half. */
|
|
106
|
+
nodeKey: string;
|
|
107
|
+
publicKeys: {
|
|
108
|
+
ed25519: string;
|
|
109
|
+
mlDsa: string;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export interface ArangoMembershipSnapshot {
|
|
113
|
+
authority: 'forgezero-platform-signed' | 'node-attested';
|
|
114
|
+
edition: 'community';
|
|
115
|
+
version: '3.11.14';
|
|
116
|
+
clusterId: string;
|
|
117
|
+
revision: string;
|
|
118
|
+
issuedAt: number;
|
|
119
|
+
expiresAt: number;
|
|
120
|
+
coordinators: readonly ArangoCoordinatorMember[];
|
|
121
|
+
}
|
|
122
|
+
export type ArangoMembershipVerifier = (snapshot: ArangoMembershipSnapshot, signal?: AbortSignal) => Promise<boolean>;
|
|
123
|
+
export declare const MAX_ARANGO_COORDINATORS = 16;
|
|
124
|
+
export declare const ARANGO_COMMUNITY_VERSION: "3.11.14";
|
|
125
|
+
/** Protected per-server API used to attest/set `default` or `readonly` mode. */
|
|
126
|
+
export declare const ARANGO_SERVER_MODE_PATH: "/_admin/server/mode";
|
|
127
|
+
/**
|
|
128
|
+
* Accept only an explicit, bounded set of private authenticated endpoints.
|
|
129
|
+
* Service discovery may propose a new list, but it must pass this authority
|
|
130
|
+
* boundary before a fresh pool is created; public Worker/KV state is never a
|
|
131
|
+
* database routing authority.
|
|
132
|
+
*/
|
|
133
|
+
export declare function arangoCoordinatorUrls(config: Pick<ArangoConfig, 'url' | 'urls'>): readonly string[];
|
|
46
134
|
export declare const arangodb: import("./index").ProviderSpec<Query, unknown[]>;
|
|
47
135
|
/** Drop memoised connections. Tests, and after a credential rotation. */
|
|
48
136
|
export declare function resetPools(): void;
|
package/dist/database.js
CHANGED
|
@@ -144,15 +144,172 @@ function createRegistry(options) {
|
|
|
144
144
|
}
|
|
145
145
|
return { call };
|
|
146
146
|
}
|
|
147
|
-
var VERSION = "0.1.
|
|
147
|
+
var VERSION = "0.1.5";
|
|
148
148
|
|
|
149
149
|
// src/database.ts
|
|
150
150
|
var pools = new Map;
|
|
151
|
+
var MAX_ARANGO_COORDINATORS = 16;
|
|
152
|
+
var ARANGO_COMMUNITY_VERSION = "3.11.14";
|
|
153
|
+
var ARANGO_SERVER_MODE_PATH = "/_admin/server/mode";
|
|
154
|
+
function arangoCoordinatorUrls(config) {
|
|
155
|
+
const input = config.urls ?? (config.url ? [config.url] : []);
|
|
156
|
+
if (input.length < 1 || input.length > MAX_ARANGO_COORDINATORS) {
|
|
157
|
+
throw new ProviderError("ARANGO_COORDINATORS_INVALID", `ArangoDB needs from 1 to ${MAX_ARANGO_COORDINATORS} private coordinator URLs.`);
|
|
158
|
+
}
|
|
159
|
+
const normalized = input.map((raw) => {
|
|
160
|
+
let value;
|
|
161
|
+
try {
|
|
162
|
+
value = new URL(raw);
|
|
163
|
+
} catch {
|
|
164
|
+
throw new ProviderError("ARANGO_COORDINATORS_INVALID", "ArangoDB coordinator URL is malformed.");
|
|
165
|
+
}
|
|
166
|
+
if (value.protocol !== "http:" && value.protocol !== "https:") {
|
|
167
|
+
throw new ProviderError("ARANGO_COORDINATORS_INVALID", "ArangoDB coordinators must use HTTP or HTTPS.");
|
|
168
|
+
}
|
|
169
|
+
if (value.username || value.password || value.search || value.hash || value.pathname && value.pathname !== "/") {
|
|
170
|
+
throw new ProviderError("ARANGO_COORDINATORS_INVALID", "ArangoDB coordinator URLs contain origins only; credentials and paths are separate.");
|
|
171
|
+
}
|
|
172
|
+
const hostname = value.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
173
|
+
const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)?.slice(1).map(Number);
|
|
174
|
+
const privateV4 = ipv4 && ipv4.every((part) => part >= 0 && part <= 255) && (ipv4[0] === 10 || ipv4[0] === 127 || ipv4[0] === 172 && ipv4[1] >= 16 && ipv4[1] <= 31 || ipv4[0] === 192 && ipv4[1] === 168);
|
|
175
|
+
const privateV6 = hostname === "::1" || /^f[cd][0-9a-f]:/i.test(hostname);
|
|
176
|
+
if (!privateV4 && !privateV6 && hostname !== "localhost") {
|
|
177
|
+
throw new ProviderError("ARANGO_COORDINATORS_INVALID", "ArangoDB coordinators must be literal private or loopback endpoints.");
|
|
178
|
+
}
|
|
179
|
+
value.hostname = value.hostname.toLowerCase();
|
|
180
|
+
return value.toString().replace(/\/$/, "");
|
|
181
|
+
});
|
|
182
|
+
if (new Set(normalized).size !== normalized.length) {
|
|
183
|
+
throw new ProviderError("ARANGO_COORDINATORS_INVALID", "ArangoDB coordinator URLs must be unique.");
|
|
184
|
+
}
|
|
185
|
+
return normalized;
|
|
186
|
+
}
|
|
187
|
+
async function resolveMembership(config, signal) {
|
|
188
|
+
if (!config.membership) {
|
|
189
|
+
const urls = arangoCoordinatorUrls(config);
|
|
190
|
+
const readOnlyUrls = config.readOnlyUrls?.length ? arangoCoordinatorUrls({ urls: config.readOnlyUrls }) : [];
|
|
191
|
+
if (urls.length + readOnlyUrls.length > MAX_ARANGO_COORDINATORS || urls.some((url) => readOnlyUrls.includes(url))) {
|
|
192
|
+
throw new ProviderError("ARANGO_COORDINATORS_INVALID", `Writable and read-only ArangoDB coordinator URLs must be disjoint and total at most ${MAX_ARANGO_COORDINATORS}.`);
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
members: [...urls.map((url) => ({
|
|
196
|
+
url,
|
|
197
|
+
role: "coordinator",
|
|
198
|
+
serverMode: "default",
|
|
199
|
+
access: "read-write",
|
|
200
|
+
capabilities: { dirtyFollowerReads: false },
|
|
201
|
+
status: "healthy"
|
|
202
|
+
})), ...readOnlyUrls.map((url) => ({
|
|
203
|
+
url,
|
|
204
|
+
role: "coordinator",
|
|
205
|
+
serverMode: "readonly",
|
|
206
|
+
access: "read-only",
|
|
207
|
+
capabilities: { dirtyFollowerReads: false },
|
|
208
|
+
status: "healthy"
|
|
209
|
+
}))],
|
|
210
|
+
identity: `static:${urls.join(",")}:readonly:${readOnlyUrls.join(",")}`
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (config.url || config.urls || config.readOnlyUrls) {
|
|
214
|
+
throw new ProviderError("ARANGO_MEMBERSHIP_AMBIGUOUS", "Configure static coordinator URLs or verified membership, not both.");
|
|
215
|
+
}
|
|
216
|
+
if (!config.clusterId || config.membership.clusterId !== config.clusterId) {
|
|
217
|
+
throw new ProviderError("ARANGO_MEMBERSHIP_CLUSTER", "Verified ArangoDB membership does not match the configured cluster.");
|
|
218
|
+
}
|
|
219
|
+
if (!config.verifyMembership || !await config.verifyMembership(config.membership, signal)) {
|
|
220
|
+
throw new ProviderError("ARANGO_MEMBERSHIP_UNVERIFIED", "Dynamic ArangoDB membership must pass the host signature or attestation verifier.");
|
|
221
|
+
}
|
|
222
|
+
const now = (config.now ?? Date.now)();
|
|
223
|
+
const snapshot = config.membership;
|
|
224
|
+
if (snapshot.edition !== "community" || snapshot.version !== ARANGO_COMMUNITY_VERSION) {
|
|
225
|
+
throw new ProviderError("ARANGO_MEMBERSHIP_CONTRACT", `Dynamic ArangoDB membership must describe Community ${ARANGO_COMMUNITY_VERSION}.`);
|
|
226
|
+
}
|
|
227
|
+
if (!Number.isFinite(snapshot.issuedAt) || !Number.isFinite(snapshot.expiresAt) || snapshot.issuedAt > now || snapshot.expiresAt <= now) {
|
|
228
|
+
throw new ProviderError("ARANGO_MEMBERSHIP_EXPIRED", "Dynamic ArangoDB membership is not currently valid.");
|
|
229
|
+
}
|
|
230
|
+
const validAuthority = snapshot.authority === "forgezero-platform-signed" || snapshot.authority === "node-attested";
|
|
231
|
+
const validNodes = snapshot.coordinators.every((node) => Number.isFinite(node.observedAt) && node.nodeKey.length > 0 && node.nodeKey === node.publicKeys?.ed25519 && Boolean(node.publicKeys.mlDsa) && (node.status === "healthy" || node.status === "degraded" || node.status === "unhealthy") && node.role === "coordinator" && (node.serverMode === "default" || node.serverMode === "readonly") && node.access === (node.serverMode === "readonly" ? "read-only" : "read-write") && node.capabilities?.dirtyFollowerReads === false);
|
|
232
|
+
if (!validAuthority || !snapshot.revision || !validNodes) {
|
|
233
|
+
throw new ProviderError("ARANGO_MEMBERSHIP_INVALID", "Dynamic ArangoDB membership needs a revision and observed timestamps.");
|
|
234
|
+
}
|
|
235
|
+
const allUrls = arangoCoordinatorUrls({ urls: snapshot.coordinators.map((node) => node.url) });
|
|
236
|
+
const members = allUrls.map((url, index) => ({ ...snapshot.coordinators[index], url })).filter((member) => member.status !== "unhealthy");
|
|
237
|
+
if (members.length === 0) {
|
|
238
|
+
throw new ProviderError("ARANGO_COORDINATORS_UNAVAILABLE", "Verified membership has no healthy or degraded ArangoDB coordinator.");
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
members,
|
|
242
|
+
identity: `membership:${snapshot.clusterId}`,
|
|
243
|
+
issuedAt: snapshot.issuedAt,
|
|
244
|
+
revision: snapshot.revision
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function retryableCoordinatorFailure(error) {
|
|
248
|
+
const failure = error;
|
|
249
|
+
const status = typeof failure.code === "number" ? failure.code : failure.status;
|
|
250
|
+
if (status === 408 || status === 429 || status === 502 || status === 503 || status === 504)
|
|
251
|
+
return true;
|
|
252
|
+
return typeof failure.code === "string" && [
|
|
253
|
+
"ECONNREFUSED",
|
|
254
|
+
"ECONNRESET",
|
|
255
|
+
"EHOSTUNREACH",
|
|
256
|
+
"ENETUNREACH",
|
|
257
|
+
"ETIMEDOUT"
|
|
258
|
+
].includes(failure.code);
|
|
259
|
+
}
|
|
260
|
+
function boundedAttempts(config, available, safe) {
|
|
261
|
+
if (!safe)
|
|
262
|
+
return 1;
|
|
263
|
+
const requested = config.maxCoordinatorAttempts ?? available;
|
|
264
|
+
if (!Number.isInteger(requested) || requested < 1 || requested > MAX_ARANGO_COORDINATORS) {
|
|
265
|
+
throw new ProviderError("ARANGO_ATTEMPTS_INVALID", `ArangoDB coordinator attempts must be from 1 to ${MAX_ARANGO_COORDINATORS}.`);
|
|
266
|
+
}
|
|
267
|
+
return Math.min(requested, available);
|
|
268
|
+
}
|
|
269
|
+
function requestIsSafe(request) {
|
|
270
|
+
if (request.retrySafety === "read")
|
|
271
|
+
return true;
|
|
272
|
+
if (request.retrySafety !== "idempotent")
|
|
273
|
+
return false;
|
|
274
|
+
if (!request.idempotencyKey || request.idempotencyKey.length > 128) {
|
|
275
|
+
throw new ProviderError("ARANGO_IDEMPOTENCY_KEY_INVALID", "An idempotent ArangoDB retry needs a non-empty idempotency key of at most 128 characters.");
|
|
276
|
+
}
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
function selectNode(pool, excluded, now, eligibleMember) {
|
|
280
|
+
const eligible = pool.nodes.filter((node) => !excluded.has(node.url) && node.unhealthyUntil <= now && eligibleMember(node));
|
|
281
|
+
if (eligible.length === 0)
|
|
282
|
+
return;
|
|
283
|
+
const least = Math.min(...eligible.map((node) => node.inFlight));
|
|
284
|
+
for (let offset = 0;offset < pool.nodes.length; offset += 1) {
|
|
285
|
+
const index = (pool.cursor + offset) % pool.nodes.length;
|
|
286
|
+
const node = pool.nodes[index];
|
|
287
|
+
if (!excluded.has(node.url) && node.unhealthyUntil <= now && eligibleMember(node) && node.inFlight === least) {
|
|
288
|
+
pool.cursor = (index + 1) % pool.nodes.length;
|
|
289
|
+
return node;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
function assertReadPolicy(request) {
|
|
295
|
+
if (request.readPreference !== undefined && request.readPreference !== "authoritative" && request.readPreference !== "prefer-readonly") {
|
|
296
|
+
throw new ProviderError("ARANGO_READ_PREFERENCE_INVALID", "Unknown ArangoDB read preference.");
|
|
297
|
+
}
|
|
298
|
+
if (request.readPreference && request.retrySafety !== "read") {
|
|
299
|
+
throw new ProviderError("ARANGO_READ_PREFERENCE_UNSAFE", "ArangoDB read preference requires an explicit read retry-safety assertion.");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function nodeForRequest(pool, request, fallback, excluded, now) {
|
|
303
|
+
const writable = (node) => node.member.access === "read-write";
|
|
304
|
+
if (request.retrySafety === "read" && request.readPreference === "prefer-readonly") {
|
|
305
|
+
return selectNode(pool, excluded, now, (node) => node.member.access === "read-only") ?? (fallback === "error" ? undefined : selectNode(pool, excluded, now, writable));
|
|
306
|
+
}
|
|
307
|
+
return selectNode(pool, excluded, now, writable);
|
|
308
|
+
}
|
|
151
309
|
var arangodb = defineProvider({
|
|
152
310
|
id: "arangodb",
|
|
153
311
|
service: "database",
|
|
154
312
|
label: "ArangoDB",
|
|
155
|
-
multiInstance: true,
|
|
156
313
|
credentials: {
|
|
157
314
|
type: "object",
|
|
158
315
|
additionalProperties: false,
|
|
@@ -164,20 +321,59 @@ var arangodb = defineProvider({
|
|
|
164
321
|
config: {
|
|
165
322
|
type: "object",
|
|
166
323
|
additionalProperties: false,
|
|
167
|
-
required: ["
|
|
324
|
+
required: ["database", "username"],
|
|
325
|
+
anyOf: [
|
|
326
|
+
{ required: ["url"] },
|
|
327
|
+
{ required: ["urls"] },
|
|
328
|
+
{ required: ["clusterId"] }
|
|
329
|
+
],
|
|
168
330
|
properties: {
|
|
169
331
|
url: {
|
|
170
332
|
type: "string",
|
|
171
333
|
title: "URL",
|
|
172
334
|
description: "Bind to loopback. A database reachable from the internet is a database that will be."
|
|
173
335
|
},
|
|
336
|
+
urls: {
|
|
337
|
+
type: "array",
|
|
338
|
+
minItems: 1,
|
|
339
|
+
maxItems: MAX_ARANGO_COORDINATORS,
|
|
340
|
+
uniqueItems: true,
|
|
341
|
+
items: { type: "string" },
|
|
342
|
+
title: "Coordinator URLs",
|
|
343
|
+
description: "Authenticated private coordinators in this one logical cluster."
|
|
344
|
+
},
|
|
345
|
+
readOnlyUrls: {
|
|
346
|
+
type: "array",
|
|
347
|
+
minItems: 1,
|
|
348
|
+
maxItems: MAX_ARANGO_COORDINATORS - 1,
|
|
349
|
+
uniqueItems: true,
|
|
350
|
+
items: { type: "string" },
|
|
351
|
+
title: "Read-only Coordinator URLs",
|
|
352
|
+
description: "Optional bootstrap endpoints whose protected Arango server mode was probed as readonly."
|
|
353
|
+
},
|
|
354
|
+
clusterId: {
|
|
355
|
+
type: "string",
|
|
356
|
+
title: "Cluster ID",
|
|
357
|
+
description: "Binds host-verified dynamic membership to one logical cluster."
|
|
358
|
+
},
|
|
174
359
|
database: { type: "string", title: "Database" },
|
|
175
360
|
username: { type: "string", title: "Username" },
|
|
176
|
-
|
|
177
|
-
type: "
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
361
|
+
maxCoordinatorAttempts: {
|
|
362
|
+
type: "integer",
|
|
363
|
+
minimum: 1,
|
|
364
|
+
maximum: MAX_ARANGO_COORDINATORS,
|
|
365
|
+
title: "Safe request attempts"
|
|
366
|
+
},
|
|
367
|
+
unhealthyForMs: {
|
|
368
|
+
type: "integer",
|
|
369
|
+
minimum: 1000,
|
|
370
|
+
maximum: 300000,
|
|
371
|
+
title: "Local coordinator quarantine (ms)"
|
|
372
|
+
},
|
|
373
|
+
readOnlyFallback: {
|
|
374
|
+
type: "string",
|
|
375
|
+
enum: ["authoritative", "error"],
|
|
376
|
+
title: "Read-only coordinator fallback"
|
|
181
377
|
}
|
|
182
378
|
}
|
|
183
379
|
},
|
|
@@ -186,35 +382,112 @@ var arangodb = defineProvider({
|
|
|
186
382
|
if (!config.connect) {
|
|
187
383
|
throw new ProviderError("ARANGO_NO_CONNECT", "Provide a connect function in this provider's config — arangojs on a server; a database is not reachable from an edge runtime.");
|
|
188
384
|
}
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
385
|
+
const membership = await resolveMembership(config, context.signal);
|
|
386
|
+
const members = membership.members;
|
|
387
|
+
const urls = members.map(({ url }) => url);
|
|
388
|
+
assertReadPolicy(request);
|
|
389
|
+
const safe = requestIsSafe(request);
|
|
390
|
+
const available = request.retrySafety === "read" && request.readPreference === "prefer-readonly" && config.readOnlyFallback === "error" ? members.filter(({ serverMode }) => serverMode === "readonly").length : members.filter(({ serverMode }) => request.retrySafety === "read" && request.readPreference === "prefer-readonly" ? true : serverMode === "default").length;
|
|
391
|
+
if (available === 0) {
|
|
392
|
+
throw new ProviderError("ARANGO_COORDINATORS_UNAVAILABLE", request.readPreference === "prefer-readonly" ? "No verified read-only ArangoDB coordinator is available under the configured fallback policy." : "No verified authoritative ArangoDB coordinator is available.");
|
|
393
|
+
}
|
|
394
|
+
const attempts = boundedAttempts(config, available, safe);
|
|
395
|
+
const key = `${membership.identity}/${config.database}/${config.username}`;
|
|
396
|
+
let pool = pools.get(key);
|
|
397
|
+
if (!pool) {
|
|
398
|
+
const password = await context.secret("password");
|
|
399
|
+
pool = {
|
|
400
|
+
nodes: members.map((member) => ({
|
|
401
|
+
url: member.url,
|
|
402
|
+
member,
|
|
403
|
+
client: config.connect({
|
|
404
|
+
url: member.url,
|
|
405
|
+
database: config.database,
|
|
406
|
+
username: config.username,
|
|
407
|
+
password,
|
|
408
|
+
loadBalancingStrategy: "NONE",
|
|
409
|
+
maxRetries: 0
|
|
410
|
+
}),
|
|
411
|
+
inFlight: 0,
|
|
412
|
+
unhealthyUntil: 0
|
|
413
|
+
})),
|
|
414
|
+
cursor: 0,
|
|
415
|
+
membershipIssuedAt: membership.issuedAt,
|
|
416
|
+
membershipRevision: membership.revision
|
|
417
|
+
};
|
|
418
|
+
pools.set(key, pool);
|
|
419
|
+
} else if (membership.issuedAt !== undefined) {
|
|
420
|
+
if (pool.membershipIssuedAt !== undefined && membership.issuedAt < pool.membershipIssuedAt) {
|
|
421
|
+
throw new ProviderError("ARANGO_MEMBERSHIP_ROLLBACK", "ArangoDB membership is older than the last accepted signed revision.");
|
|
422
|
+
}
|
|
423
|
+
const existing = new Map(pool.nodes.map((node) => [node.url, node]));
|
|
424
|
+
const missing = urls.filter((url) => !existing.has(url));
|
|
425
|
+
const password = missing.length > 0 ? await context.secret("password") : undefined;
|
|
426
|
+
const nextNodes = members.map((member) => {
|
|
427
|
+
const retained2 = existing.get(member.url);
|
|
428
|
+
if (retained2) {
|
|
429
|
+
retained2.member = member;
|
|
430
|
+
return retained2;
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
url: member.url,
|
|
434
|
+
member,
|
|
435
|
+
client: config.connect({
|
|
436
|
+
url: member.url,
|
|
437
|
+
database: config.database,
|
|
438
|
+
username: config.username,
|
|
439
|
+
password,
|
|
440
|
+
loadBalancingStrategy: "NONE",
|
|
441
|
+
maxRetries: 0
|
|
442
|
+
}),
|
|
443
|
+
inFlight: 0,
|
|
444
|
+
unhealthyUntil: 0
|
|
445
|
+
};
|
|
197
446
|
});
|
|
198
|
-
|
|
447
|
+
const retained = new Set(urls);
|
|
448
|
+
for (const removed of pool.nodes.filter((node) => !retained.has(node.url) && node.inFlight === 0)) {
|
|
449
|
+
try {
|
|
450
|
+
await removed.client.close?.();
|
|
451
|
+
} catch {}
|
|
452
|
+
}
|
|
453
|
+
pool.nodes = nextNodes;
|
|
454
|
+
pool.cursor %= nextNodes.length;
|
|
455
|
+
pool.membershipIssuedAt = membership.issuedAt;
|
|
456
|
+
pool.membershipRevision = membership.revision;
|
|
457
|
+
}
|
|
458
|
+
const tried = new Set;
|
|
459
|
+
let lastError;
|
|
460
|
+
for (let attempt = 0;attempt < attempts; attempt += 1) {
|
|
461
|
+
if (context.signal?.aborted) {
|
|
462
|
+
throw context.signal.reason ?? new ProviderError("ARANGO_ABORTED", "ArangoDB request was cancelled.");
|
|
463
|
+
}
|
|
464
|
+
const now = (config.now ?? Date.now)();
|
|
465
|
+
const node = nodeForRequest(pool, request, config.readOnlyFallback, tried, now);
|
|
466
|
+
if (!node)
|
|
467
|
+
break;
|
|
468
|
+
tried.add(node.url);
|
|
469
|
+
node.inFlight += 1;
|
|
470
|
+
try {
|
|
471
|
+
const cursor = await node.client.query(request.query, request.bindVars);
|
|
472
|
+
const result = await cursor.all();
|
|
473
|
+
node.unhealthyUntil = 0;
|
|
474
|
+
return result;
|
|
475
|
+
} catch (error) {
|
|
476
|
+
lastError = error;
|
|
477
|
+
if (!retryableCoordinatorFailure(error))
|
|
478
|
+
throw error;
|
|
479
|
+
const quarantine = Math.min(Math.max(config.unhealthyForMs ?? 15000, 1000), 300000);
|
|
480
|
+
node.unhealthyUntil = now + quarantine;
|
|
481
|
+
if (!safe)
|
|
482
|
+
throw error;
|
|
483
|
+
} finally {
|
|
484
|
+
node.inFlight -= 1;
|
|
485
|
+
}
|
|
199
486
|
}
|
|
200
|
-
|
|
201
|
-
return cursor.all();
|
|
487
|
+
throw lastError ?? new ProviderError("ARANGO_COORDINATORS_UNAVAILABLE", "No healthy ArangoDB coordinator is available.");
|
|
202
488
|
},
|
|
203
|
-
classify(
|
|
204
|
-
|
|
205
|
-
if (arango.errorNum === 1210)
|
|
206
|
-
return "terminal";
|
|
207
|
-
if (arango.errorNum === 1202)
|
|
208
|
-
return "terminal";
|
|
209
|
-
if (arango.errorNum === 1501)
|
|
210
|
-
return "terminal";
|
|
211
|
-
if (arango.code === 400 || arango.code === 404 || arango.code === 409)
|
|
212
|
-
return "terminal";
|
|
213
|
-
if (arango.code === 401 || arango.code === 403)
|
|
214
|
-
return "retryable";
|
|
215
|
-
if (arango.code === 503)
|
|
216
|
-
return "backoff";
|
|
217
|
-
return "retryable";
|
|
489
|
+
classify() {
|
|
490
|
+
return "terminal";
|
|
218
491
|
}
|
|
219
492
|
});
|
|
220
493
|
function resetPools() {
|
|
@@ -224,5 +497,9 @@ var databaseProviders = [arangodb];
|
|
|
224
497
|
export {
|
|
225
498
|
resetPools,
|
|
226
499
|
databaseProviders,
|
|
227
|
-
arangodb
|
|
500
|
+
arangodb,
|
|
501
|
+
arangoCoordinatorUrls,
|
|
502
|
+
MAX_ARANGO_COORDINATORS,
|
|
503
|
+
ARANGO_SERVER_MODE_PATH,
|
|
504
|
+
ARANGO_COMMUNITY_VERSION
|
|
228
505
|
};
|
package/dist/email.js
CHANGED
package/dist/http.js
CHANGED
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/pool.js
CHANGED
package/dist/storage.js
CHANGED
package/dist/translation.js
CHANGED