@feltdb/core 0.13.0 → 0.15.0
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/agent-runtime.d.ts +64 -2
- package/dist/agent-runtime.d.ts.map +1 -1
- package/dist/agent-runtime.js +121 -11
- package/dist/agent-state.d.ts +48 -0
- package/dist/agent-state.d.ts.map +1 -1
- package/dist/agent-state.js +174 -12
- package/dist/agent.d.ts +10 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +10 -1
- package/dist/build-identity.json +4 -4
- package/dist/cli/commands.js +27 -6
- package/dist/collection.d.ts +3 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +30 -8
- package/dist/create/build-identity.json +4 -4
- package/dist/create/cli.js +1 -1
- package/dist/create/create.js +4 -6
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +6 -6
- package/dist/create/server-source/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb/src/lib.rs +126 -35
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +162 -53
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +137 -1
- package/dist/create/server-source/crates/feltdb/src/workload.rs +1427 -91
- package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +85 -25
- package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
- package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
- package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
- package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
- package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +3 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +879 -84
- package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
- package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
- package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
- package/dist/db.d.ts +64 -9
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +153 -27
- package/dist/deployment.d.ts +19 -0
- package/dist/deployment.d.ts.map +1 -1
- package/dist/deployment.js +38 -2
- package/dist/embedded-query.d.ts.map +1 -1
- package/dist/embedded-query.js +5 -4
- package/dist/embedded-transaction.d.ts +8 -4
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +55 -19
- package/dist/error-codes.d.ts +31 -2
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +41 -2
- package/dist/feltdb.d.ts +7 -0
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.d.ts +27 -0
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +145 -7
- package/dist/http-client.d.ts +11 -3
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +27 -14
- package/dist/http-db.d.ts +27 -1
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +252 -54
- package/dist/index.js +1 -1
- package/dist/local-state-lock.d.ts +34 -0
- package/dist/local-state-lock.d.ts.map +1 -0
- package/dist/local-state-lock.js +225 -0
- package/dist/reactive-graph.d.ts +0 -3
- package/dist/reactive-graph.d.ts.map +1 -1
- package/dist/reactive-graph.js +11 -1
- package/dist/runtime-registry.d.ts +7 -2
- package/dist/runtime-registry.d.ts.map +1 -1
- package/dist/runtime-registry.js +2 -2
- package/dist/studio/components/index.js +1 -1
- package/dist/studio/{components-BSGmHfif.js → components-CJmzntBi.js} +2 -1
- package/dist/studio/index.js +1 -1
- package/dist/studio-app/assets/{feltdb_wasm-3hDngl1h.js → feltdb_wasm-BjM1ylL3.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-TseJn2Ax.wasm +0 -0
- package/dist/studio-app/assets/index-1wd0uKua.js +30 -0
- package/dist/studio-app/index.html +1 -1
- package/dist/transaction.d.ts +27 -11
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +20 -14
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/wasm/package.json +1 -1
- package/dist/workload.d.ts +31 -3
- package/dist/workload.d.ts.map +1 -1
- package/dist/workload.js +28 -3
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
- package/dist/studio-app/assets/index-Dql1hS5m.js +0 -30
package/dist/http-db.js
CHANGED
|
@@ -1,6 +1,111 @@
|
|
|
1
1
|
import { FeltDBErrorCode, FeltDBServiceError } from './error-codes.js';
|
|
2
|
+
import { assertBrowserSafeCredential } from './deployment.js';
|
|
2
3
|
import { conditionalRefusal } from './transaction.js';
|
|
3
4
|
import { BoundedBackoff, SingleFlightValue, cancellableDelay, describeFailure, } from './managed-recovery.js';
|
|
5
|
+
/** The origin of a URL, without any userinfo, for messages. */
|
|
6
|
+
function displayOrigin(url) {
|
|
7
|
+
try {
|
|
8
|
+
return new URL(url).origin;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return url.replace(/\/\/[^@/]*@/, '//');
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const AUTHENTICATION_CODES = new Set(['AUTHENTICATION_REQUIRED', 'AUTHENTICATION_FAILED', 'UNAUTHORIZED', 'UNAUTHENTICATED']);
|
|
15
|
+
const LOAD_CODES = new Set(['SERVICE_OVERLOADED', 'TOO_BUSY', 'RATE_LIMITED', 'TOO_MANY_REQUESTS', 'QUERY_LIMIT_EXCEEDED']);
|
|
16
|
+
/**
|
|
17
|
+
* What to do next about a refusal the authority answered, or undefined when
|
|
18
|
+
* the authority's own message already says it. The code is never changed;
|
|
19
|
+
* this only adds the remedy a developer would otherwise have to look up.
|
|
20
|
+
*/
|
|
21
|
+
function refusalRemedy(code, status, message, context) {
|
|
22
|
+
const application = context.applicationId ? `application "${context.applicationId}"` : 'this authority';
|
|
23
|
+
const environment = context.environment ?? 'production';
|
|
24
|
+
if (code === 'AUTHORITY_SCOPE_MISMATCH') {
|
|
25
|
+
return `The tenant named in createFeltDB({ authorityScope }) does not own ${application}; use the tenant the application belongs to, or omit authorityScope.`;
|
|
26
|
+
}
|
|
27
|
+
if (status === 401 || AUTHENTICATION_CODES.has(code)) {
|
|
28
|
+
return context.hasToken
|
|
29
|
+
? `${context.origin} did not accept the credential. Check FELTDB_TOKEN: it must be an unexpired, unrevoked data key${context.applicationId ? ` issued for ${application}` : ''}. Keep it server-side only.`
|
|
30
|
+
: 'No credential was sent. Set FELTDB_TOKEN in the server process (never in browser code), or pass createFeltDB({ server: { token } }).';
|
|
31
|
+
}
|
|
32
|
+
if (code === 'UNKNOWN_COLLECTION') {
|
|
33
|
+
const collection = context.collection ?? (/^[A-Za-z_][\w.-]*$/.test(message) ? message : undefined);
|
|
34
|
+
return `Collection ${collection ? `"${collection}" ` : ''}is not declared in the contract promoted to environment "${environment}" for ${application}. Add it to the contract and publish and promote a new revision.`;
|
|
35
|
+
}
|
|
36
|
+
if (status === 403 || code === 'FORBIDDEN') {
|
|
37
|
+
// Discovery checks application:read; a canonical read or write also
|
|
38
|
+
// resolves the promoted revision (application:revision:read).
|
|
39
|
+
const scope = context.operation === 'write' ? 'the application:revision:read and state:write scopes'
|
|
40
|
+
: context.operation === 'discover' ? 'the application:read scope'
|
|
41
|
+
: context.operation === 'read' ? 'the application:revision:read and state:read scopes'
|
|
42
|
+
: 'the scopes this operation needs (state:read to read, state:write to write)';
|
|
43
|
+
return `The credential was accepted but is not authorized for ${application}${context.applicationId ? ` in environment "${environment}"` : ''}. Use a data key minted for this application with ${scope}`
|
|
44
|
+
+ (context.operation === 'write' && context.collection
|
|
45
|
+
? `; a write to a collection the promoted contract does not declare ("${context.collection}" may be one) is refused the same way.`
|
|
46
|
+
: '.');
|
|
47
|
+
}
|
|
48
|
+
if (status === 404 && context.applicationId) {
|
|
49
|
+
if (/no active contract/i.test(message)) {
|
|
50
|
+
return `No contract revision is promoted to environment "${environment}" for ${application}. Publish and promote one (feltdb publish --environment ${environment}), or set FELTDB_ENVIRONMENT to an environment that has one.`;
|
|
51
|
+
}
|
|
52
|
+
if (/application not found/i.test(message)) {
|
|
53
|
+
return `Check FELTDB_APPLICATION_ID: ${context.origin} has no ${application} visible to this credential.`;
|
|
54
|
+
}
|
|
55
|
+
if (/revision not found/i.test(message)) {
|
|
56
|
+
return `The revision promoted for ${application} is not readable; promote the revision again.`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if ((code === 'VALIDATION_FAILED' || code === 'VALIDATION_ERROR') && /unknown field/i.test(message)) {
|
|
60
|
+
return `Declare the field on ${context.collection ? `collection "${context.collection}"` : 'the collection'} in the contract and promote it, or stop writing it.`;
|
|
61
|
+
}
|
|
62
|
+
if (code === FeltDBErrorCode.DURABILITY_FAILURE) {
|
|
63
|
+
return 'Whether the commit is durable is unknown: retry with the same transactionId, and never treat this as a refusal.';
|
|
64
|
+
}
|
|
65
|
+
if (status === 429 || LOAD_CODES.has(code) || (status === 503 && code === FeltDBErrorCode.SERVICE_UNAVAILABLE)) {
|
|
66
|
+
return context.operation === 'write'
|
|
67
|
+
? 'The authority is shedding load: back off (honour Retry-After) and retry with the same transactionId.'
|
|
68
|
+
: 'The authority is shedding load: back off (honour Retry-After) and retry the read.';
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
/** The authority's message plus its code, status, request id and remedy. */
|
|
73
|
+
function refusalMessage(message, code, status, requestId, context) {
|
|
74
|
+
if (!context)
|
|
75
|
+
return message;
|
|
76
|
+
const remedy = refusalRemedy(code, status, message, context);
|
|
77
|
+
const where = [status ? `HTTP ${status}` : undefined, code, requestId ? `request ${requestId}` : undefined].filter(Boolean).join(', ');
|
|
78
|
+
return `${message} (${where})${remedy ? `. ${remedy}` : ''}`;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The typed failure for a request that ended without an answer. A read is
|
|
82
|
+
* `SERVICE_UNAVAILABLE`; a mutation may have reached the authority and
|
|
83
|
+
* committed, so it is `COMMIT_OUTCOME_UNKNOWN`, never a refusal.
|
|
84
|
+
*/
|
|
85
|
+
function transportFailure(error, mutating, context) {
|
|
86
|
+
if (error instanceof FeltDBServiceError)
|
|
87
|
+
return error;
|
|
88
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
89
|
+
const cause = error?.cause;
|
|
90
|
+
const name = error?.name;
|
|
91
|
+
const timedOut = name === 'TimeoutError' || name === 'AbortError';
|
|
92
|
+
// Only a request that ended without an answer gets the connection advice;
|
|
93
|
+
// any other local error (a malformed key, say) keeps its own message.
|
|
94
|
+
const transport = timedOut || error instanceof TypeError || typeof cause?.code === 'string';
|
|
95
|
+
const detail = typeof cause?.code === 'string' ? cause.code : typeof cause?.message === 'string' ? cause.message : raw;
|
|
96
|
+
const message = !context || !transport ? raw
|
|
97
|
+
: `${timedOut ? `No answer from FeltDB at ${context.origin} within ${context.timeoutMs} ms` : `Could not reach FeltDB at ${context.origin} (${detail})`}. `
|
|
98
|
+
+ (mutating
|
|
99
|
+
? 'The write may have been applied: retry only with the same transactionId, never a new one. '
|
|
100
|
+
: 'Nothing was changed. ')
|
|
101
|
+
+ `Check FELTDB_URL and the service: GET ${context.origin}/health answers while the process is up, ${context.origin}/ready only when it can serve.`;
|
|
102
|
+
return new FeltDBServiceError(message, mutating ? FeltDBErrorCode.COMMIT_OUTCOME_UNKNOWN : FeltDBErrorCode.SERVICE_UNAVAILABLE);
|
|
103
|
+
}
|
|
104
|
+
/** A failed `JsResult` that keeps the typed failure. */
|
|
105
|
+
function failed(error, mutating, context) {
|
|
106
|
+
const cause = transportFailure(error, mutating, context);
|
|
107
|
+
return { success: false, error: cause.message, cause };
|
|
108
|
+
}
|
|
4
109
|
/**
|
|
5
110
|
* A durable identity for one conditional write attempt.
|
|
6
111
|
*
|
|
@@ -29,6 +134,19 @@ function managedCollectionRecord(value) {
|
|
|
29
134
|
delete record._version;
|
|
30
135
|
return record;
|
|
31
136
|
}
|
|
137
|
+
/** The collection a canonical request body addresses, for failure messages. */
|
|
138
|
+
function requestCollection(body) {
|
|
139
|
+
if (typeof body !== 'string')
|
|
140
|
+
return undefined;
|
|
141
|
+
try {
|
|
142
|
+
const value = JSON.parse(body);
|
|
143
|
+
const collection = value.query?.collection ?? value.transaction?.operations?.[0]?.collection;
|
|
144
|
+
return typeof collection === 'string' ? collection : undefined;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
32
150
|
/** Network adapter implementing the same collection runtime contract as WASM. */
|
|
33
151
|
export class HttpJsDb {
|
|
34
152
|
collection_queries_via_authority = true;
|
|
@@ -60,6 +178,8 @@ export class HttpJsDb {
|
|
|
60
178
|
changeListeners = new Set();
|
|
61
179
|
changeStream;
|
|
62
180
|
constructor(options) {
|
|
181
|
+
if (!options.allowServiceKeyInBrowser)
|
|
182
|
+
assertBrowserSafeCredential(options.token, typeof window !== 'undefined' && typeof document !== 'undefined', 'the HTTP runtime token');
|
|
63
183
|
this.url = options.url.replace(/\/$/, '');
|
|
64
184
|
this.token = options.token || '';
|
|
65
185
|
this.applicationId = options.applicationId;
|
|
@@ -127,6 +247,14 @@ export class HttpJsDb {
|
|
|
127
247
|
...(json ? { 'Content-Type': 'application/json' } : {}),
|
|
128
248
|
};
|
|
129
249
|
}
|
|
250
|
+
/** Where this client's requests go, for actionable failure messages. */
|
|
251
|
+
context(operation, collection) {
|
|
252
|
+
return {
|
|
253
|
+
origin: displayOrigin(this.url), timeoutMs: this.requestTimeoutMs, hasToken: !!this.token,
|
|
254
|
+
...(this.applicationId ? { applicationId: this.applicationId, environment: this.environment } : {}),
|
|
255
|
+
...(operation ? { operation } : {}), ...(collection ? { collection } : {}),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
130
258
|
updateToken(token) {
|
|
131
259
|
this.token = token;
|
|
132
260
|
// Credentials changed, so a permanent authorization failure may no longer
|
|
@@ -202,11 +330,16 @@ export class HttpJsDb {
|
|
|
202
330
|
revisionState() {
|
|
203
331
|
return this.revisionCache.state;
|
|
204
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* A canonical *read* (`/query`, `/transactions/read-basis`). It is a POST
|
|
335
|
+
* only because its request has a body; an interrupted read changed nothing,
|
|
336
|
+
* so it is `SERVICE_UNAVAILABLE`, never `COMMIT_OUTCOME_UNKNOWN`.
|
|
337
|
+
*/
|
|
205
338
|
async canonical(path, payload) {
|
|
206
339
|
const revisionId = await this.revisionId();
|
|
207
340
|
return this.application_request(path, { method: 'POST', body: JSON.stringify({
|
|
208
341
|
application_id: this.applicationId, revision_id: revisionId, environment: this.environment, ...payload,
|
|
209
|
-
}) });
|
|
342
|
+
}) }, { read: true });
|
|
210
343
|
}
|
|
211
344
|
transactionDocument(revisionId, operations, transactionId = crypto.randomUUID(), preconditions = [], fences = []) {
|
|
212
345
|
const tenantId = this.authorityScope?.kind === 'tenant' ? this.authorityScope.tenantId : '';
|
|
@@ -229,6 +362,7 @@ export class HttpJsDb {
|
|
|
229
362
|
}
|
|
230
363
|
const response = await fetch(`${this.url}/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, {
|
|
231
364
|
headers: this.headers(),
|
|
365
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
232
366
|
});
|
|
233
367
|
if (response.status === 404)
|
|
234
368
|
return { success: true };
|
|
@@ -238,7 +372,7 @@ export class HttpJsDb {
|
|
|
238
372
|
return { success: true, data: JSON.stringify(record.value) };
|
|
239
373
|
}
|
|
240
374
|
catch (error) {
|
|
241
|
-
return
|
|
375
|
+
return failed(error, false, this.context('read'));
|
|
242
376
|
}
|
|
243
377
|
}
|
|
244
378
|
async query(collection) {
|
|
@@ -257,6 +391,7 @@ export class HttpJsDb {
|
|
|
257
391
|
}
|
|
258
392
|
const response = await fetch(`${this.url}/collections/${encodeURIComponent(collection)}`, {
|
|
259
393
|
headers: this.headers(),
|
|
394
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
260
395
|
});
|
|
261
396
|
if (!response.ok)
|
|
262
397
|
return this.failure(response);
|
|
@@ -264,7 +399,7 @@ export class HttpJsDb {
|
|
|
264
399
|
return { success: true, data: JSON.stringify(records.map(record => record.value)) };
|
|
265
400
|
}
|
|
266
401
|
catch (error) {
|
|
267
|
-
return
|
|
402
|
+
return failed(error, false, this.context('read'));
|
|
268
403
|
}
|
|
269
404
|
}
|
|
270
405
|
async authority_query(query) {
|
|
@@ -289,17 +424,21 @@ export class HttpJsDb {
|
|
|
289
424
|
} });
|
|
290
425
|
return { records: result.records.map(managedCollectionRecord), nextCursor: result.next_cursor, exhausted: !result.next_cursor, plan: result.plan };
|
|
291
426
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
error.code = body.code;
|
|
299
|
-
error.status = response.status;
|
|
300
|
-
throw error;
|
|
427
|
+
let response;
|
|
428
|
+
try {
|
|
429
|
+
response = await fetch(`${this.url}/query`, {
|
|
430
|
+
method: 'POST', headers: this.headers(true), body: JSON.stringify(query),
|
|
431
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
432
|
+
});
|
|
301
433
|
}
|
|
302
|
-
|
|
434
|
+
catch (error) {
|
|
435
|
+
// A bounded query is a read: an interruption is SERVICE_UNAVAILABLE.
|
|
436
|
+
this.recordFailure(error);
|
|
437
|
+
throw transportFailure(error, false, this.context('read', query.collection));
|
|
438
|
+
}
|
|
439
|
+
if (!response.ok)
|
|
440
|
+
throw await this.refusal(response);
|
|
441
|
+
return await response.json();
|
|
303
442
|
}
|
|
304
443
|
async insert(key, value) {
|
|
305
444
|
try {
|
|
@@ -313,13 +452,14 @@ export class HttpJsDb {
|
|
|
313
452
|
method: 'POST',
|
|
314
453
|
headers: this.headers(true),
|
|
315
454
|
body: JSON.stringify({ ...record, id }),
|
|
455
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
316
456
|
});
|
|
317
457
|
if (!response.ok)
|
|
318
458
|
return this.failure(response);
|
|
319
459
|
return { success: true, data: key };
|
|
320
460
|
}
|
|
321
461
|
catch (error) {
|
|
322
|
-
return
|
|
462
|
+
return failed(error, true, this.context('write'));
|
|
323
463
|
}
|
|
324
464
|
}
|
|
325
465
|
async update(key, value) {
|
|
@@ -333,13 +473,14 @@ export class HttpJsDb {
|
|
|
333
473
|
method: 'PATCH',
|
|
334
474
|
headers: this.headers(true),
|
|
335
475
|
body: value,
|
|
476
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
336
477
|
});
|
|
337
478
|
if (!response.ok)
|
|
338
479
|
return this.failure(response);
|
|
339
480
|
return { success: true, data: key };
|
|
340
481
|
}
|
|
341
482
|
catch (error) {
|
|
342
|
-
return
|
|
483
|
+
return failed(error, true, this.context('write'));
|
|
343
484
|
}
|
|
344
485
|
}
|
|
345
486
|
/**
|
|
@@ -395,7 +536,7 @@ export class HttpJsDb {
|
|
|
395
536
|
}
|
|
396
537
|
catch (error) {
|
|
397
538
|
this.recordFailure(error);
|
|
398
|
-
throw
|
|
539
|
+
throw transportFailure(error, true, this.context('write', request.operations[0]?.collection));
|
|
399
540
|
}
|
|
400
541
|
if (!response.ok) {
|
|
401
542
|
// The authority answers a refused transaction with a structured
|
|
@@ -407,12 +548,13 @@ export class HttpJsDb {
|
|
|
407
548
|
body = await response.json();
|
|
408
549
|
}
|
|
409
550
|
catch { /* not JSON */ }
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
551
|
+
const code = body.code
|
|
552
|
+
?? (response.status >= 500 ? FeltDBErrorCode.SERVICE_UNAVAILABLE : FeltDBErrorCode.INVALID_REQUEST);
|
|
553
|
+
const error = new FeltDBServiceError(refusalMessage((code === body.error ? body.message : body.error) || body.message || `transaction failed with HTTP ${response.status}`, code, response.status, body.request_id, this.context('write', request.operations[0]?.collection)), code, body.request_id, response.status);
|
|
554
|
+
error.feltdbCode = code;
|
|
414
555
|
if (body.failure)
|
|
415
556
|
error.feltdbConflict = body.failure;
|
|
557
|
+
this.recordFailure(error);
|
|
416
558
|
throw error;
|
|
417
559
|
}
|
|
418
560
|
const body = await response.json();
|
|
@@ -566,17 +708,28 @@ export class HttpJsDb {
|
|
|
566
708
|
try {
|
|
567
709
|
const [collection, id] = this.splitKey(params.key);
|
|
568
710
|
const parsed = JSON.parse(params.value);
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
711
|
+
let response;
|
|
712
|
+
try {
|
|
713
|
+
response = await fetch(`${this.url}/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/cas`, {
|
|
714
|
+
method: 'POST',
|
|
715
|
+
headers: this.headers(true),
|
|
716
|
+
body: JSON.stringify({
|
|
717
|
+
expectedVersion: params.expectedVersion,
|
|
718
|
+
expectedEpoch: params.expectedEpoch,
|
|
719
|
+
expectedLeaseId: params.expectedLeaseId,
|
|
720
|
+
rejectIfDiverged: params.rejectIfDiverged,
|
|
721
|
+
value: parsed,
|
|
722
|
+
}),
|
|
723
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
catch (error) {
|
|
727
|
+
// The request may have reached the authority and committed. A CAS
|
|
728
|
+
// carries no identity to retry by, so the caller reconciles by
|
|
729
|
+
// re-reading: a retried CAS is refused by the version it advanced.
|
|
730
|
+
this.recordFailure(error);
|
|
731
|
+
throw new FeltDBServiceError(error instanceof Error ? error.message : String(error), FeltDBErrorCode.COMMIT_OUTCOME_UNKNOWN);
|
|
732
|
+
}
|
|
580
733
|
// Handle conflict response (structured error)
|
|
581
734
|
if (response.status === 409) {
|
|
582
735
|
const body = await response.json();
|
|
@@ -590,10 +743,14 @@ export class HttpJsDb {
|
|
|
590
743
|
};
|
|
591
744
|
}
|
|
592
745
|
}
|
|
593
|
-
// Other errors
|
|
746
|
+
// Other errors, typed by the authority's code.
|
|
594
747
|
if (!response.ok) {
|
|
595
|
-
|
|
596
|
-
|
|
748
|
+
let body = {};
|
|
749
|
+
try {
|
|
750
|
+
body = await response.json();
|
|
751
|
+
}
|
|
752
|
+
catch { /* not JSON */ }
|
|
753
|
+
throw new FeltDBServiceError(body.message || body.error || `HTTP CAS failed with ${response.status}`, body.code || (response.status >= 500 ? FeltDBErrorCode.SERVICE_UNAVAILABLE : FeltDBErrorCode.INVALID_REQUEST), body.request_id, response.status);
|
|
597
754
|
}
|
|
598
755
|
// Success response
|
|
599
756
|
const result = await response.json();
|
|
@@ -613,11 +770,12 @@ export class HttpJsDb {
|
|
|
613
770
|
const response = await fetch(`${this.url}/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, {
|
|
614
771
|
method: 'DELETE',
|
|
615
772
|
headers: this.headers(),
|
|
773
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
616
774
|
});
|
|
617
775
|
return response.ok ? { success: true, data: key } : this.failure(response);
|
|
618
776
|
}
|
|
619
777
|
catch (error) {
|
|
620
|
-
return
|
|
778
|
+
return failed(error, true, this.context('write'));
|
|
621
779
|
}
|
|
622
780
|
}
|
|
623
781
|
/** Acquire a reference through the server's causal peer fabric. */
|
|
@@ -626,7 +784,7 @@ export class HttpJsDb {
|
|
|
626
784
|
if (response.status === 404)
|
|
627
785
|
return null;
|
|
628
786
|
if (!response.ok)
|
|
629
|
-
throw
|
|
787
|
+
throw await this.refusal(response);
|
|
630
788
|
return (await response.json()).value;
|
|
631
789
|
}
|
|
632
790
|
/** Execute the built-in state-derived search capability remotely. */
|
|
@@ -635,19 +793,19 @@ export class HttpJsDb {
|
|
|
635
793
|
method: 'POST', headers: this.headers(true), body: JSON.stringify({ query, limit }),
|
|
636
794
|
});
|
|
637
795
|
if (!response.ok)
|
|
638
|
-
throw
|
|
796
|
+
throw await this.refusal(response);
|
|
639
797
|
return (await response.json()).map(record => record.value);
|
|
640
798
|
}
|
|
641
799
|
async provenance(collection, id) {
|
|
642
800
|
const response = await fetch(`${this.url}/provenance/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, { headers: this.headers() });
|
|
643
801
|
if (!response.ok)
|
|
644
|
-
throw
|
|
802
|
+
throw await this.refusal(response);
|
|
645
803
|
return (await response.json()).value;
|
|
646
804
|
}
|
|
647
805
|
async storeContent(content) {
|
|
648
806
|
const response = await fetch(`${this.url}/content`, { method: 'PUT', headers: this.headers(), body: content });
|
|
649
807
|
if (!response.ok)
|
|
650
|
-
throw
|
|
808
|
+
throw await this.refusal(response);
|
|
651
809
|
return response.json();
|
|
652
810
|
}
|
|
653
811
|
async acquireContent(hash) {
|
|
@@ -655,7 +813,7 @@ export class HttpJsDb {
|
|
|
655
813
|
if (response.status === 404)
|
|
656
814
|
return null;
|
|
657
815
|
if (!response.ok)
|
|
658
|
-
throw
|
|
816
|
+
throw await this.refusal(response);
|
|
659
817
|
return new Uint8Array(await response.arrayBuffer());
|
|
660
818
|
}
|
|
661
819
|
async command(path, body) {
|
|
@@ -663,19 +821,28 @@ export class HttpJsDb {
|
|
|
663
821
|
method: 'POST', headers: this.headers(true), body: JSON.stringify(body),
|
|
664
822
|
});
|
|
665
823
|
if (!response.ok)
|
|
666
|
-
throw
|
|
824
|
+
throw await this.refusal(response);
|
|
667
825
|
return response.status === 204 ? undefined : response.json();
|
|
668
826
|
}
|
|
669
|
-
|
|
827
|
+
/**
|
|
828
|
+
* One request to the canonical application service. A request without a
|
|
829
|
+
* body-carrying method is a read; a POST is treated as a mutation unless the
|
|
830
|
+
* caller declares it a read (`{ read: true }`), because only a mutation can
|
|
831
|
+
* leave an uncertain commit behind.
|
|
832
|
+
*/
|
|
833
|
+
async application_request(path, init = {}, options = {}) {
|
|
670
834
|
const separator = path.includes('?') ? '&' : '?';
|
|
671
|
-
const
|
|
835
|
+
const query = this.applicationId
|
|
672
836
|
? `${separator}application_id=${encodeURIComponent(this.applicationId)}&environment=${encodeURIComponent(this.environment)}`
|
|
673
837
|
: '';
|
|
674
838
|
if (this.availabilityState === 'degraded')
|
|
675
839
|
this.availabilityState = 'recovering';
|
|
840
|
+
const mutating = !options.read && !['GET', 'HEAD', 'OPTIONS'].includes((init.method ?? 'GET').toUpperCase());
|
|
841
|
+
// Built only on failure: parsing the request body is not free.
|
|
842
|
+
const context = () => this.context(path.startsWith('/application') ? 'discover' : mutating ? 'write' : 'read', requestCollection(init.body));
|
|
676
843
|
let response;
|
|
677
844
|
try {
|
|
678
|
-
response = await fetch(`${this.url}/v1${path}${
|
|
845
|
+
response = await fetch(`${this.url}/v1${path}${query}`, {
|
|
679
846
|
...init,
|
|
680
847
|
signal: init.signal ?? AbortSignal.timeout(this.requestTimeoutMs),
|
|
681
848
|
headers: { ...this.headers(init.body !== undefined), ...(init.headers || {}) },
|
|
@@ -683,10 +850,17 @@ export class HttpJsDb {
|
|
|
683
850
|
}
|
|
684
851
|
catch (error) {
|
|
685
852
|
this.recordFailure(error);
|
|
686
|
-
|
|
687
|
-
|
|
853
|
+
throw transportFailure(error, mutating, context());
|
|
854
|
+
}
|
|
855
|
+
let text;
|
|
856
|
+
try {
|
|
857
|
+
text = await response.text();
|
|
858
|
+
}
|
|
859
|
+
catch (error) {
|
|
860
|
+
// The answer started and was cut off: for a mutation, still unknown.
|
|
861
|
+
this.recordFailure(error);
|
|
862
|
+
throw transportFailure(error, mutating, context());
|
|
688
863
|
}
|
|
689
|
-
const text = await response.text();
|
|
690
864
|
let body;
|
|
691
865
|
try {
|
|
692
866
|
body = text ? JSON.parse(text) : undefined;
|
|
@@ -695,7 +869,8 @@ export class HttpJsDb {
|
|
|
695
869
|
body = { message: text };
|
|
696
870
|
}
|
|
697
871
|
if (!response.ok) {
|
|
698
|
-
const
|
|
872
|
+
const code = body?.code || body?.error || 'REQUEST_FAILED';
|
|
873
|
+
const error = new FeltDBServiceError(refusalMessage(body?.message || body?.error || `FeltDB service request failed: HTTP ${response.status}`, code, response.status, body?.request_id, context()), code, body?.request_id, response.status, body && typeof body === 'object' && !Array.isArray(body) ? body : undefined);
|
|
699
874
|
// The same structured refusal the direct route carries, so a lost race
|
|
700
875
|
// is typed the same way on every route.
|
|
701
876
|
if (body?.failure)
|
|
@@ -733,7 +908,7 @@ export class HttpJsDb {
|
|
|
733
908
|
const error = new FeltDBServiceError(body?.message || body?.error || `FeltDB request failed: HTTP ${response.status}`, body?.code || (response.status === 401 ? FeltDBErrorCode.UNAUTHORIZED
|
|
734
909
|
: response.status === 403 ? FeltDBErrorCode.FORBIDDEN
|
|
735
910
|
: response.status === 404 ? FeltDBErrorCode.NOT_FOUND
|
|
736
|
-
: 'INVALID_REQUEST'), body?.request_id, response.status);
|
|
911
|
+
: 'INVALID_REQUEST'), body?.request_id, response.status, body && typeof body === 'object' && !Array.isArray(body) ? body : undefined);
|
|
737
912
|
this.recordFailure(error);
|
|
738
913
|
throw error;
|
|
739
914
|
}
|
|
@@ -877,15 +1052,38 @@ export class HttpJsDb {
|
|
|
877
1052
|
this.connectionState = 'closed';
|
|
878
1053
|
this.nextRetryAt = undefined;
|
|
879
1054
|
}
|
|
1055
|
+
/** A refused response as a failed `JsResult` carrying the typed refusal. */
|
|
880
1056
|
async failure(response) {
|
|
881
|
-
|
|
1057
|
+
const cause = await this.refusal(response);
|
|
1058
|
+
return { success: false, error: cause.message, cause };
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* The authority's refusal, typed: its stable `code` (or one derived from the
|
|
1062
|
+
* status when the body carries none), status, request id and body.
|
|
1063
|
+
*/
|
|
1064
|
+
async refusal(response) {
|
|
1065
|
+
let body;
|
|
882
1066
|
try {
|
|
883
|
-
const
|
|
884
|
-
if (
|
|
885
|
-
|
|
1067
|
+
const parsed = await response.json();
|
|
1068
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
1069
|
+
body = parsed;
|
|
886
1070
|
}
|
|
887
1071
|
catch { /* response was not JSON */ }
|
|
888
|
-
|
|
1072
|
+
const text = (name) => (typeof body?.[name] === 'string' ? body[name] : undefined);
|
|
1073
|
+
// An `error` field is a code only when it is spelled like one; older
|
|
1074
|
+
// answers put prose there.
|
|
1075
|
+
const errorField = text('error');
|
|
1076
|
+
const errorCode = errorField && /^[A-Z][A-Z0-9_]+$/.test(errorField) ? errorField : undefined;
|
|
1077
|
+
const code = text('code') ?? errorCode ?? (response.status === 401 ? FeltDBErrorCode.AUTHENTICATION_REQUIRED
|
|
1078
|
+
: response.status === 403 ? FeltDBErrorCode.FORBIDDEN
|
|
1079
|
+
: response.status === 404 ? FeltDBErrorCode.NOT_FOUND
|
|
1080
|
+
: response.status === 409 ? FeltDBErrorCode.CONFLICT
|
|
1081
|
+
: response.status >= 500 ? FeltDBErrorCode.SERVICE_UNAVAILABLE
|
|
1082
|
+
: FeltDBErrorCode.INVALID_REQUEST);
|
|
1083
|
+
const message = refusalMessage(text('message') ?? (errorField !== code ? errorField : undefined) ?? `HTTP ${response.status}`, code, response.status, text('request_id'), this.context());
|
|
1084
|
+
const error = new FeltDBServiceError(message, code, text('request_id'), response.status, body);
|
|
1085
|
+
this.recordFailure(error);
|
|
1086
|
+
return error;
|
|
889
1087
|
}
|
|
890
1088
|
get_capability_records(capability) { return this.query(capability); }
|
|
891
1089
|
execute_op() { return { success: false, error: 'Raw operation execution is internal to the server fabric' }; }
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import { registerDeploymentConfigLoader } from './deployment.js';
|
|
|
15
15
|
import { FileJsDb } from './file-db.js';
|
|
16
16
|
import * as nodeFs from 'fs';
|
|
17
17
|
import * as nodePath from 'path';
|
|
18
|
-
registerRuntime('file', path => new FileJsDb(path));
|
|
18
|
+
registerRuntime('file', (path, options) => new FileJsDb(path, options?.access ? { access: options.access } : {}));
|
|
19
19
|
registerDeploymentConfigLoader(() => {
|
|
20
20
|
const configPath = nodePath.join(process.cwd(), 'feltdb.config.json');
|
|
21
21
|
if (!nodeFs.existsSync(configPath))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exclusive ownership of a local state directory.
|
|
3
|
+
*
|
|
4
|
+
* The local file runtime is safe for several processes at the level of a
|
|
5
|
+
* single commit (each mutation takes `state.lock`). An application usually
|
|
6
|
+
* wants more: that no *other* process opens its state at all, because two
|
|
7
|
+
* application processes over one directory each believe they are the only
|
|
8
|
+
* writer of their caches, schedules, and in-process subscriptions.
|
|
9
|
+
*
|
|
10
|
+
* `acquire` creates `owner.lock` in the directory with `O_EXCL` and records
|
|
11
|
+
* the holder (`pid`, `host`, a random token). A second process finds it and is
|
|
12
|
+
* refused with `LocalStateLockedError`. Handles in the same process share the
|
|
13
|
+
* lock and the last `release` removes it; process exit removes it too.
|
|
14
|
+
*
|
|
15
|
+
* A lock whose holder is gone is recoverable: when the recorded host is this
|
|
16
|
+
* host and `process.kill(pid, 0)` reports no such process, the lock is taken
|
|
17
|
+
* over. The takeover renames the old lock aside and checks it is the one that
|
|
18
|
+
* was judged stale before deleting it, so two processes recovering the same
|
|
19
|
+
* stale lock cannot both win. A lock recorded by another host, or one that
|
|
20
|
+
* cannot be read, is never judged stale (it is refused, and the error names
|
|
21
|
+
* the file to remove by hand). A lock records its directory's real path, so a
|
|
22
|
+
* lock copied with the directory (a backup, an upgrade's verification copy)
|
|
23
|
+
* does not hold the copy.
|
|
24
|
+
*
|
|
25
|
+
* This is not authority state: it holds no data, and removing it never
|
|
26
|
+
* changes what the store contains.
|
|
27
|
+
*/
|
|
28
|
+
export declare const LOCAL_STATE_LOCK_FILE = "owner.lock";
|
|
29
|
+
/**
|
|
30
|
+
* Take exclusive ownership of `directory` for this process.
|
|
31
|
+
* Throws `LocalStateLockedError` when another live process holds it.
|
|
32
|
+
*/
|
|
33
|
+
export declare function acquireLocalStateLock(directory: string): () => void;
|
|
34
|
+
//# sourceMappingURL=local-state-lock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local-state-lock.d.ts","sourceRoot":"","sources":["../src/local-state-lock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAOH,eAAO,MAAM,qBAAqB,eAAe,CAAC;AAiHlD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,IAAI,CAoDnE"}
|