@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.
Files changed (92) hide show
  1. package/dist/agent-runtime.d.ts +64 -2
  2. package/dist/agent-runtime.d.ts.map +1 -1
  3. package/dist/agent-runtime.js +121 -11
  4. package/dist/agent-state.d.ts +48 -0
  5. package/dist/agent-state.d.ts.map +1 -1
  6. package/dist/agent-state.js +174 -12
  7. package/dist/agent.d.ts +10 -1
  8. package/dist/agent.d.ts.map +1 -1
  9. package/dist/agent.js +10 -1
  10. package/dist/build-identity.json +4 -4
  11. package/dist/cli/commands.js +27 -6
  12. package/dist/collection.d.ts +3 -1
  13. package/dist/collection.d.ts.map +1 -1
  14. package/dist/collection.js +30 -8
  15. package/dist/create/build-identity.json +4 -4
  16. package/dist/create/cli.js +1 -1
  17. package/dist/create/create.js +4 -6
  18. package/dist/create/package-versions.js +1 -1
  19. package/dist/create/server-source/Cargo.lock +6 -6
  20. package/dist/create/server-source/Cargo.toml +1 -1
  21. package/dist/create/server-source/crates/feltdb/src/lib.rs +126 -35
  22. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +162 -53
  23. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +137 -1
  24. package/dist/create/server-source/crates/feltdb/src/workload.rs +1427 -91
  25. package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
  26. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
  27. package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
  28. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
  29. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +85 -25
  30. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
  31. package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
  32. package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
  33. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
  34. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
  35. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +3 -0
  36. package/dist/create/server-source/crates/feltdb-server/src/main.rs +879 -84
  37. package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
  38. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
  39. package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
  40. package/dist/db.d.ts +64 -9
  41. package/dist/db.d.ts.map +1 -1
  42. package/dist/db.js +153 -27
  43. package/dist/deployment.d.ts +19 -0
  44. package/dist/deployment.d.ts.map +1 -1
  45. package/dist/deployment.js +38 -2
  46. package/dist/embedded-query.d.ts.map +1 -1
  47. package/dist/embedded-query.js +5 -4
  48. package/dist/embedded-transaction.d.ts +8 -4
  49. package/dist/embedded-transaction.d.ts.map +1 -1
  50. package/dist/embedded-transaction.js +55 -19
  51. package/dist/error-codes.d.ts +31 -2
  52. package/dist/error-codes.d.ts.map +1 -1
  53. package/dist/error-codes.js +41 -2
  54. package/dist/feltdb.d.ts +7 -0
  55. package/dist/feltdb.d.ts.map +1 -1
  56. package/dist/file-db.d.ts +27 -0
  57. package/dist/file-db.d.ts.map +1 -1
  58. package/dist/file-db.js +145 -7
  59. package/dist/http-client.d.ts +11 -3
  60. package/dist/http-client.d.ts.map +1 -1
  61. package/dist/http-client.js +27 -14
  62. package/dist/http-db.d.ts +27 -1
  63. package/dist/http-db.d.ts.map +1 -1
  64. package/dist/http-db.js +252 -54
  65. package/dist/index.js +1 -1
  66. package/dist/local-state-lock.d.ts +34 -0
  67. package/dist/local-state-lock.d.ts.map +1 -0
  68. package/dist/local-state-lock.js +225 -0
  69. package/dist/reactive-graph.d.ts +0 -3
  70. package/dist/reactive-graph.d.ts.map +1 -1
  71. package/dist/reactive-graph.js +11 -1
  72. package/dist/runtime-registry.d.ts +7 -2
  73. package/dist/runtime-registry.d.ts.map +1 -1
  74. package/dist/runtime-registry.js +2 -2
  75. package/dist/studio/components/index.js +1 -1
  76. package/dist/studio/{components-BSGmHfif.js → components-CJmzntBi.js} +2 -1
  77. package/dist/studio/index.js +1 -1
  78. package/dist/studio-app/assets/{feltdb_wasm-3hDngl1h.js → feltdb_wasm-BjM1ylL3.js} +1 -1
  79. package/dist/studio-app/assets/feltdb_wasm_bg-TseJn2Ax.wasm +0 -0
  80. package/dist/studio-app/assets/index-1wd0uKua.js +30 -0
  81. package/dist/studio-app/index.html +1 -1
  82. package/dist/transaction.d.ts +27 -11
  83. package/dist/transaction.d.ts.map +1 -1
  84. package/dist/transaction.js +20 -14
  85. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  86. package/dist/wasm/package.json +1 -1
  87. package/dist/workload.d.ts +31 -3
  88. package/dist/workload.d.ts.map +1 -1
  89. package/dist/workload.js +28 -3
  90. package/package.json +1 -1
  91. package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
  92. 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 { success: false, error: String(error) };
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 { success: false, error: String(error) };
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
- const response = await fetch(`${this.url}/query`, {
293
- method: 'POST', headers: this.headers(true), body: JSON.stringify(query),
294
- });
295
- const body = await response.json();
296
- if (!response.ok) {
297
- const error = new Error(body.message || body.error || `authority query failed with HTTP ${response.status}`);
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
- return body;
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 { success: false, error: String(error) };
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 { success: false, error: String(error) };
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 new FeltDBServiceError(error instanceof Error ? error.message : String(error), FeltDBErrorCode.COMMIT_OUTCOME_UNKNOWN);
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 error = new Error(body.error || `transaction failed with HTTP ${response.status}`);
411
- error.status = response.status;
412
- if (body.code)
413
- error.feltdbCode = body.code;
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
- const response = await fetch(`${this.url}/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/cas`, {
570
- method: 'POST',
571
- headers: this.headers(true),
572
- body: JSON.stringify({
573
- expectedVersion: params.expectedVersion,
574
- expectedEpoch: params.expectedEpoch,
575
- expectedLeaseId: params.expectedLeaseId,
576
- rejectIfDiverged: params.rejectIfDiverged,
577
- value: parsed,
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
- const failure = await this.failure(response);
596
- throw new Error(failure.error || 'HTTP CAS failed');
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 { success: false, error: String(error) };
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 new Error((await this.failure(response)).error);
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 new Error((await this.failure(response)).error);
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 new Error((await this.failure(response)).error);
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 new Error((await this.failure(response)).error);
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 new Error((await this.failure(response)).error);
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 new Error((await this.failure(response)).error);
824
+ throw await this.refusal(response);
667
825
  return response.status === 204 ? undefined : response.json();
668
826
  }
669
- async application_request(path, init = {}) {
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 context = this.applicationId
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}${context}`, {
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
- const mutating = !['GET', 'HEAD', 'OPTIONS'].includes((init.method ?? 'GET').toUpperCase());
687
- throw new FeltDBServiceError(error instanceof Error ? error.message : String(error), mutating ? FeltDBErrorCode.COMMIT_OUTCOME_UNKNOWN : FeltDBErrorCode.SERVICE_UNAVAILABLE);
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 error = new FeltDBServiceError(body?.message || body?.error || `FeltDB service request failed: HTTP ${response.status}`, body?.code || body?.error || 'REQUEST_FAILED', body?.request_id, response.status);
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
- let detail = `HTTP ${response.status}`;
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 body = await response.json();
884
- if (body.error)
885
- detail = body.error;
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
- return { success: false, error: detail };
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"}