@feltdb/core 0.12.2 → 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 +34 -7
- package/dist/cli/workspace-integration.js +8 -0
- 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 +47 -38
- package/dist/create/create.js +21 -8
- 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 +341 -2
- package/dist/create/server-source/crates/feltdb/src/workload.rs +2094 -126
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +18 -12
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +17 -5
- 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/agent_execution.rs +511 -0
- 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 +113 -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 +4 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +1740 -236
- 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/create/setup-summary.js +40 -0
- package/dist/db.d.ts +91 -10
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +170 -31
- package/dist/deployment.d.ts +19 -0
- package/dist/deployment.d.ts.map +1 -1
- package/dist/deployment.js +38 -2
- package/dist/development-runtime-bridge.d.ts +20 -0
- package/dist/development-runtime-bridge.d.ts.map +1 -1
- package/dist/development-runtime-bridge.js +61 -12
- 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 +40 -1
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +285 -58
- 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/{KeyManagementPanel-D5FMvvU9.js → KeyManagementPanel-skUNnKRw.js} +36 -29
- package/dist/studio/app.d.ts +8 -1
- package/dist/studio/app.d.ts.map +1 -1
- package/dist/studio/components/KeyManagementPanel.d.ts.map +1 -1
- package/dist/studio/components/KeyManagementPanel.js +1 -1
- package/dist/studio/components/index.js +2 -2
- package/dist/studio/{components-B4eKIUN5.js → components-CJmzntBi.js} +8 -7
- package/dist/studio/control-plane/Data.d.ts.map +1 -1
- package/dist/studio/control-plane/Instance.d.ts +4 -1
- package/dist/studio/control-plane/Instance.d.ts.map +1 -1
- package/dist/studio/control-plane/Operations.d.ts +4 -1
- package/dist/studio/control-plane/Operations.d.ts.map +1 -1
- package/dist/studio/control-plane/index.js +2 -2
- package/dist/studio/control-plane/primitives.d.ts +16 -0
- package/dist/studio/control-plane/primitives.d.ts.map +1 -1
- package/dist/studio/{control-plane-fxMcJn74.js → control-plane-CaBA7Rgv.js} +448 -424
- package/dist/studio/index.d.ts +1 -0
- package/dist/studio/index.d.ts.map +1 -1
- package/dist/studio/index.js +384 -247
- package/dist/studio/navigation.d.ts +57 -0
- package/dist/studio/navigation.d.ts.map +1 -0
- package/dist/studio/studio.css +1 -1
- package/dist/studio/utils/control-plane-api.d.ts +2 -0
- package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
- package/dist/studio/utils/control-plane-api.js +5 -1
- package/dist/studio-app/assets/{feltdb_wasm-BatwwogY.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/assets/{index-CiC8VY-a.css → index-vi21oEQa.css} +1 -1
- package/dist/studio-app/index.html +2 -2
- 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/dist/workspace/local-development-authority.d.ts +19 -0
- package/dist/workspace/local-development-authority.d.ts.map +1 -1
- package/dist/workspace/local-development-authority.js +335 -15
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-BX0newOr.wasm +0 -0
- package/dist/studio-app/assets/index-DoVvbIay.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;
|
|
@@ -56,7 +174,12 @@ export class HttpJsDb {
|
|
|
56
174
|
nextRetryAt;
|
|
57
175
|
streamFailures = 0;
|
|
58
176
|
authorityFailures = 0;
|
|
177
|
+
/** One entry per subscription, so the same callback subscribed twice is two subscriptions. */
|
|
178
|
+
changeListeners = new Set();
|
|
179
|
+
changeStream;
|
|
59
180
|
constructor(options) {
|
|
181
|
+
if (!options.allowServiceKeyInBrowser)
|
|
182
|
+
assertBrowserSafeCredential(options.token, typeof window !== 'undefined' && typeof document !== 'undefined', 'the HTTP runtime token');
|
|
60
183
|
this.url = options.url.replace(/\/$/, '');
|
|
61
184
|
this.token = options.token || '';
|
|
62
185
|
this.applicationId = options.applicationId;
|
|
@@ -124,6 +247,14 @@ export class HttpJsDb {
|
|
|
124
247
|
...(json ? { 'Content-Type': 'application/json' } : {}),
|
|
125
248
|
};
|
|
126
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
|
+
}
|
|
127
258
|
updateToken(token) {
|
|
128
259
|
this.token = token;
|
|
129
260
|
// Credentials changed, so a permanent authorization failure may no longer
|
|
@@ -199,11 +330,16 @@ export class HttpJsDb {
|
|
|
199
330
|
revisionState() {
|
|
200
331
|
return this.revisionCache.state;
|
|
201
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
|
+
*/
|
|
202
338
|
async canonical(path, payload) {
|
|
203
339
|
const revisionId = await this.revisionId();
|
|
204
340
|
return this.application_request(path, { method: 'POST', body: JSON.stringify({
|
|
205
341
|
application_id: this.applicationId, revision_id: revisionId, environment: this.environment, ...payload,
|
|
206
|
-
}) });
|
|
342
|
+
}) }, { read: true });
|
|
207
343
|
}
|
|
208
344
|
transactionDocument(revisionId, operations, transactionId = crypto.randomUUID(), preconditions = [], fences = []) {
|
|
209
345
|
const tenantId = this.authorityScope?.kind === 'tenant' ? this.authorityScope.tenantId : '';
|
|
@@ -226,6 +362,7 @@ export class HttpJsDb {
|
|
|
226
362
|
}
|
|
227
363
|
const response = await fetch(`${this.url}/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, {
|
|
228
364
|
headers: this.headers(),
|
|
365
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
229
366
|
});
|
|
230
367
|
if (response.status === 404)
|
|
231
368
|
return { success: true };
|
|
@@ -235,7 +372,7 @@ export class HttpJsDb {
|
|
|
235
372
|
return { success: true, data: JSON.stringify(record.value) };
|
|
236
373
|
}
|
|
237
374
|
catch (error) {
|
|
238
|
-
return
|
|
375
|
+
return failed(error, false, this.context('read'));
|
|
239
376
|
}
|
|
240
377
|
}
|
|
241
378
|
async query(collection) {
|
|
@@ -254,6 +391,7 @@ export class HttpJsDb {
|
|
|
254
391
|
}
|
|
255
392
|
const response = await fetch(`${this.url}/collections/${encodeURIComponent(collection)}`, {
|
|
256
393
|
headers: this.headers(),
|
|
394
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
257
395
|
});
|
|
258
396
|
if (!response.ok)
|
|
259
397
|
return this.failure(response);
|
|
@@ -261,7 +399,7 @@ export class HttpJsDb {
|
|
|
261
399
|
return { success: true, data: JSON.stringify(records.map(record => record.value)) };
|
|
262
400
|
}
|
|
263
401
|
catch (error) {
|
|
264
|
-
return
|
|
402
|
+
return failed(error, false, this.context('read'));
|
|
265
403
|
}
|
|
266
404
|
}
|
|
267
405
|
async authority_query(query) {
|
|
@@ -286,17 +424,21 @@ export class HttpJsDb {
|
|
|
286
424
|
} });
|
|
287
425
|
return { records: result.records.map(managedCollectionRecord), nextCursor: result.next_cursor, exhausted: !result.next_cursor, plan: result.plan };
|
|
288
426
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
error.code = body.code;
|
|
296
|
-
error.status = response.status;
|
|
297
|
-
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
|
+
});
|
|
298
433
|
}
|
|
299
|
-
|
|
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();
|
|
300
442
|
}
|
|
301
443
|
async insert(key, value) {
|
|
302
444
|
try {
|
|
@@ -310,13 +452,14 @@ export class HttpJsDb {
|
|
|
310
452
|
method: 'POST',
|
|
311
453
|
headers: this.headers(true),
|
|
312
454
|
body: JSON.stringify({ ...record, id }),
|
|
455
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
313
456
|
});
|
|
314
457
|
if (!response.ok)
|
|
315
458
|
return this.failure(response);
|
|
316
459
|
return { success: true, data: key };
|
|
317
460
|
}
|
|
318
461
|
catch (error) {
|
|
319
|
-
return
|
|
462
|
+
return failed(error, true, this.context('write'));
|
|
320
463
|
}
|
|
321
464
|
}
|
|
322
465
|
async update(key, value) {
|
|
@@ -330,13 +473,14 @@ export class HttpJsDb {
|
|
|
330
473
|
method: 'PATCH',
|
|
331
474
|
headers: this.headers(true),
|
|
332
475
|
body: value,
|
|
476
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
333
477
|
});
|
|
334
478
|
if (!response.ok)
|
|
335
479
|
return this.failure(response);
|
|
336
480
|
return { success: true, data: key };
|
|
337
481
|
}
|
|
338
482
|
catch (error) {
|
|
339
|
-
return
|
|
483
|
+
return failed(error, true, this.context('write'));
|
|
340
484
|
}
|
|
341
485
|
}
|
|
342
486
|
/**
|
|
@@ -392,7 +536,7 @@ export class HttpJsDb {
|
|
|
392
536
|
}
|
|
393
537
|
catch (error) {
|
|
394
538
|
this.recordFailure(error);
|
|
395
|
-
throw
|
|
539
|
+
throw transportFailure(error, true, this.context('write', request.operations[0]?.collection));
|
|
396
540
|
}
|
|
397
541
|
if (!response.ok) {
|
|
398
542
|
// The authority answers a refused transaction with a structured
|
|
@@ -404,12 +548,13 @@ export class HttpJsDb {
|
|
|
404
548
|
body = await response.json();
|
|
405
549
|
}
|
|
406
550
|
catch { /* not JSON */ }
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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;
|
|
411
555
|
if (body.failure)
|
|
412
556
|
error.feltdbConflict = body.failure;
|
|
557
|
+
this.recordFailure(error);
|
|
413
558
|
throw error;
|
|
414
559
|
}
|
|
415
560
|
const body = await response.json();
|
|
@@ -563,17 +708,28 @@ export class HttpJsDb {
|
|
|
563
708
|
try {
|
|
564
709
|
const [collection, id] = this.splitKey(params.key);
|
|
565
710
|
const parsed = JSON.parse(params.value);
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
+
}
|
|
577
733
|
// Handle conflict response (structured error)
|
|
578
734
|
if (response.status === 409) {
|
|
579
735
|
const body = await response.json();
|
|
@@ -587,10 +743,14 @@ export class HttpJsDb {
|
|
|
587
743
|
};
|
|
588
744
|
}
|
|
589
745
|
}
|
|
590
|
-
// Other errors
|
|
746
|
+
// Other errors, typed by the authority's code.
|
|
591
747
|
if (!response.ok) {
|
|
592
|
-
|
|
593
|
-
|
|
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);
|
|
594
754
|
}
|
|
595
755
|
// Success response
|
|
596
756
|
const result = await response.json();
|
|
@@ -610,11 +770,12 @@ export class HttpJsDb {
|
|
|
610
770
|
const response = await fetch(`${this.url}/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, {
|
|
611
771
|
method: 'DELETE',
|
|
612
772
|
headers: this.headers(),
|
|
773
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
613
774
|
});
|
|
614
775
|
return response.ok ? { success: true, data: key } : this.failure(response);
|
|
615
776
|
}
|
|
616
777
|
catch (error) {
|
|
617
|
-
return
|
|
778
|
+
return failed(error, true, this.context('write'));
|
|
618
779
|
}
|
|
619
780
|
}
|
|
620
781
|
/** Acquire a reference through the server's causal peer fabric. */
|
|
@@ -623,7 +784,7 @@ export class HttpJsDb {
|
|
|
623
784
|
if (response.status === 404)
|
|
624
785
|
return null;
|
|
625
786
|
if (!response.ok)
|
|
626
|
-
throw
|
|
787
|
+
throw await this.refusal(response);
|
|
627
788
|
return (await response.json()).value;
|
|
628
789
|
}
|
|
629
790
|
/** Execute the built-in state-derived search capability remotely. */
|
|
@@ -632,19 +793,19 @@ export class HttpJsDb {
|
|
|
632
793
|
method: 'POST', headers: this.headers(true), body: JSON.stringify({ query, limit }),
|
|
633
794
|
});
|
|
634
795
|
if (!response.ok)
|
|
635
|
-
throw
|
|
796
|
+
throw await this.refusal(response);
|
|
636
797
|
return (await response.json()).map(record => record.value);
|
|
637
798
|
}
|
|
638
799
|
async provenance(collection, id) {
|
|
639
800
|
const response = await fetch(`${this.url}/provenance/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, { headers: this.headers() });
|
|
640
801
|
if (!response.ok)
|
|
641
|
-
throw
|
|
802
|
+
throw await this.refusal(response);
|
|
642
803
|
return (await response.json()).value;
|
|
643
804
|
}
|
|
644
805
|
async storeContent(content) {
|
|
645
806
|
const response = await fetch(`${this.url}/content`, { method: 'PUT', headers: this.headers(), body: content });
|
|
646
807
|
if (!response.ok)
|
|
647
|
-
throw
|
|
808
|
+
throw await this.refusal(response);
|
|
648
809
|
return response.json();
|
|
649
810
|
}
|
|
650
811
|
async acquireContent(hash) {
|
|
@@ -652,7 +813,7 @@ export class HttpJsDb {
|
|
|
652
813
|
if (response.status === 404)
|
|
653
814
|
return null;
|
|
654
815
|
if (!response.ok)
|
|
655
|
-
throw
|
|
816
|
+
throw await this.refusal(response);
|
|
656
817
|
return new Uint8Array(await response.arrayBuffer());
|
|
657
818
|
}
|
|
658
819
|
async command(path, body) {
|
|
@@ -660,19 +821,28 @@ export class HttpJsDb {
|
|
|
660
821
|
method: 'POST', headers: this.headers(true), body: JSON.stringify(body),
|
|
661
822
|
});
|
|
662
823
|
if (!response.ok)
|
|
663
|
-
throw
|
|
824
|
+
throw await this.refusal(response);
|
|
664
825
|
return response.status === 204 ? undefined : response.json();
|
|
665
826
|
}
|
|
666
|
-
|
|
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 = {}) {
|
|
667
834
|
const separator = path.includes('?') ? '&' : '?';
|
|
668
|
-
const
|
|
835
|
+
const query = this.applicationId
|
|
669
836
|
? `${separator}application_id=${encodeURIComponent(this.applicationId)}&environment=${encodeURIComponent(this.environment)}`
|
|
670
837
|
: '';
|
|
671
838
|
if (this.availabilityState === 'degraded')
|
|
672
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));
|
|
673
843
|
let response;
|
|
674
844
|
try {
|
|
675
|
-
response = await fetch(`${this.url}/v1${path}${
|
|
845
|
+
response = await fetch(`${this.url}/v1${path}${query}`, {
|
|
676
846
|
...init,
|
|
677
847
|
signal: init.signal ?? AbortSignal.timeout(this.requestTimeoutMs),
|
|
678
848
|
headers: { ...this.headers(init.body !== undefined), ...(init.headers || {}) },
|
|
@@ -680,10 +850,17 @@ export class HttpJsDb {
|
|
|
680
850
|
}
|
|
681
851
|
catch (error) {
|
|
682
852
|
this.recordFailure(error);
|
|
683
|
-
|
|
684
|
-
|
|
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());
|
|
685
863
|
}
|
|
686
|
-
const text = await response.text();
|
|
687
864
|
let body;
|
|
688
865
|
try {
|
|
689
866
|
body = text ? JSON.parse(text) : undefined;
|
|
@@ -692,7 +869,8 @@ export class HttpJsDb {
|
|
|
692
869
|
body = { message: text };
|
|
693
870
|
}
|
|
694
871
|
if (!response.ok) {
|
|
695
|
-
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);
|
|
696
874
|
// The same structured refusal the direct route carries, so a lost race
|
|
697
875
|
// is typed the same way on every route.
|
|
698
876
|
if (body?.failure)
|
|
@@ -730,7 +908,7 @@ export class HttpJsDb {
|
|
|
730
908
|
const error = new FeltDBServiceError(body?.message || body?.error || `FeltDB request failed: HTTP ${response.status}`, body?.code || (response.status === 401 ? FeltDBErrorCode.UNAUTHORIZED
|
|
731
909
|
: response.status === 403 ? FeltDBErrorCode.FORBIDDEN
|
|
732
910
|
: response.status === 404 ? FeltDBErrorCode.NOT_FOUND
|
|
733
|
-
: 'INVALID_REQUEST'), body?.request_id, response.status);
|
|
911
|
+
: 'INVALID_REQUEST'), body?.request_id, response.status, body && typeof body === 'object' && !Array.isArray(body) ? body : undefined);
|
|
734
912
|
this.recordFailure(error);
|
|
735
913
|
throw error;
|
|
736
914
|
}
|
|
@@ -749,14 +927,40 @@ export class HttpJsDb {
|
|
|
749
927
|
* a timer that wakes up after the caller has unsubscribed and opens a new
|
|
750
928
|
* connection to an authority nobody is listening to.
|
|
751
929
|
*/
|
|
930
|
+
/**
|
|
931
|
+
* One event stream per client, shared by every subscriber.
|
|
932
|
+
*
|
|
933
|
+
* A browser holds at most six HTTP/1.1 connections to one host, and an event
|
|
934
|
+
* stream holds its connection for as long as it is open. A stream per live
|
|
935
|
+
* collection therefore lets an application with six live collections occupy
|
|
936
|
+
* every connection to its authority, after which each other request — the
|
|
937
|
+
* next write included — waits forever. Subscribers are fanned out here
|
|
938
|
+
* instead, and the stream closes with the last of them.
|
|
939
|
+
*/
|
|
752
940
|
subscribe_changes(callback) {
|
|
753
|
-
const
|
|
754
|
-
this.
|
|
755
|
-
|
|
941
|
+
const subscription = { callback };
|
|
942
|
+
this.changeListeners.add(subscription);
|
|
943
|
+
if (!this.changeStream) {
|
|
944
|
+
const controller = new AbortController();
|
|
945
|
+
this.changeStream = controller;
|
|
946
|
+
this.connectionState = 'connecting';
|
|
947
|
+
void this.consumeEvents(controller, collection => {
|
|
948
|
+
for (const listener of [...this.changeListeners]) {
|
|
949
|
+
// One subscriber's failure is its own; the others still hear the change.
|
|
950
|
+
try {
|
|
951
|
+
listener.callback(collection);
|
|
952
|
+
}
|
|
953
|
+
catch { /* reported by the subscriber */ }
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
}
|
|
756
957
|
return () => {
|
|
958
|
+
if (!this.changeListeners.delete(subscription) || this.changeListeners.size > 0 || !this.changeStream)
|
|
959
|
+
return;
|
|
757
960
|
// `abort()` reaches both: the in-flight fetch through its signal, and
|
|
758
961
|
// the pending backoff through the listener `cancellableDelay` installs.
|
|
759
|
-
|
|
962
|
+
this.changeStream.abort();
|
|
963
|
+
this.changeStream = undefined;
|
|
760
964
|
this.connectionState = 'closed';
|
|
761
965
|
this.nextRetryAt = undefined;
|
|
762
966
|
};
|
|
@@ -848,15 +1052,38 @@ export class HttpJsDb {
|
|
|
848
1052
|
this.connectionState = 'closed';
|
|
849
1053
|
this.nextRetryAt = undefined;
|
|
850
1054
|
}
|
|
1055
|
+
/** A refused response as a failed `JsResult` carrying the typed refusal. */
|
|
851
1056
|
async failure(response) {
|
|
852
|
-
|
|
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;
|
|
853
1066
|
try {
|
|
854
|
-
const
|
|
855
|
-
if (
|
|
856
|
-
|
|
1067
|
+
const parsed = await response.json();
|
|
1068
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
1069
|
+
body = parsed;
|
|
857
1070
|
}
|
|
858
1071
|
catch { /* response was not JSON */ }
|
|
859
|
-
|
|
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;
|
|
860
1087
|
}
|
|
861
1088
|
get_capability_records(capability) { return this.query(capability); }
|
|
862
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"}
|