@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.
Files changed (126) 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 +34 -7
  12. package/dist/cli/workspace-integration.js +8 -0
  13. package/dist/collection.d.ts +3 -1
  14. package/dist/collection.d.ts.map +1 -1
  15. package/dist/collection.js +30 -8
  16. package/dist/create/build-identity.json +4 -4
  17. package/dist/create/cli.js +47 -38
  18. package/dist/create/create.js +21 -8
  19. package/dist/create/package-versions.js +1 -1
  20. package/dist/create/server-source/Cargo.lock +6 -6
  21. package/dist/create/server-source/Cargo.toml +1 -1
  22. package/dist/create/server-source/crates/feltdb/src/lib.rs +126 -35
  23. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +162 -53
  24. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +341 -2
  25. package/dist/create/server-source/crates/feltdb/src/workload.rs +2094 -126
  26. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +18 -12
  27. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +17 -5
  28. package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
  29. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
  30. package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
  31. package/dist/create/server-source/crates/feltdb-server/src/agent_execution.rs +511 -0
  32. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
  33. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +113 -25
  34. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
  35. package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
  36. package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
  37. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
  38. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
  39. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +4 -0
  40. package/dist/create/server-source/crates/feltdb-server/src/main.rs +1740 -236
  41. package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
  42. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
  43. package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
  44. package/dist/create/setup-summary.js +40 -0
  45. package/dist/db.d.ts +91 -10
  46. package/dist/db.d.ts.map +1 -1
  47. package/dist/db.js +170 -31
  48. package/dist/deployment.d.ts +19 -0
  49. package/dist/deployment.d.ts.map +1 -1
  50. package/dist/deployment.js +38 -2
  51. package/dist/development-runtime-bridge.d.ts +20 -0
  52. package/dist/development-runtime-bridge.d.ts.map +1 -1
  53. package/dist/development-runtime-bridge.js +61 -12
  54. package/dist/embedded-query.d.ts.map +1 -1
  55. package/dist/embedded-query.js +5 -4
  56. package/dist/embedded-transaction.d.ts +8 -4
  57. package/dist/embedded-transaction.d.ts.map +1 -1
  58. package/dist/embedded-transaction.js +55 -19
  59. package/dist/error-codes.d.ts +31 -2
  60. package/dist/error-codes.d.ts.map +1 -1
  61. package/dist/error-codes.js +41 -2
  62. package/dist/feltdb.d.ts +7 -0
  63. package/dist/feltdb.d.ts.map +1 -1
  64. package/dist/file-db.d.ts +27 -0
  65. package/dist/file-db.d.ts.map +1 -1
  66. package/dist/file-db.js +145 -7
  67. package/dist/http-client.d.ts +11 -3
  68. package/dist/http-client.d.ts.map +1 -1
  69. package/dist/http-client.js +27 -14
  70. package/dist/http-db.d.ts +40 -1
  71. package/dist/http-db.d.ts.map +1 -1
  72. package/dist/http-db.js +285 -58
  73. package/dist/index.js +1 -1
  74. package/dist/local-state-lock.d.ts +34 -0
  75. package/dist/local-state-lock.d.ts.map +1 -0
  76. package/dist/local-state-lock.js +225 -0
  77. package/dist/reactive-graph.d.ts +0 -3
  78. package/dist/reactive-graph.d.ts.map +1 -1
  79. package/dist/reactive-graph.js +11 -1
  80. package/dist/runtime-registry.d.ts +7 -2
  81. package/dist/runtime-registry.d.ts.map +1 -1
  82. package/dist/runtime-registry.js +2 -2
  83. package/dist/studio/{KeyManagementPanel-D5FMvvU9.js → KeyManagementPanel-skUNnKRw.js} +36 -29
  84. package/dist/studio/app.d.ts +8 -1
  85. package/dist/studio/app.d.ts.map +1 -1
  86. package/dist/studio/components/KeyManagementPanel.d.ts.map +1 -1
  87. package/dist/studio/components/KeyManagementPanel.js +1 -1
  88. package/dist/studio/components/index.js +2 -2
  89. package/dist/studio/{components-B4eKIUN5.js → components-CJmzntBi.js} +8 -7
  90. package/dist/studio/control-plane/Data.d.ts.map +1 -1
  91. package/dist/studio/control-plane/Instance.d.ts +4 -1
  92. package/dist/studio/control-plane/Instance.d.ts.map +1 -1
  93. package/dist/studio/control-plane/Operations.d.ts +4 -1
  94. package/dist/studio/control-plane/Operations.d.ts.map +1 -1
  95. package/dist/studio/control-plane/index.js +2 -2
  96. package/dist/studio/control-plane/primitives.d.ts +16 -0
  97. package/dist/studio/control-plane/primitives.d.ts.map +1 -1
  98. package/dist/studio/{control-plane-fxMcJn74.js → control-plane-CaBA7Rgv.js} +448 -424
  99. package/dist/studio/index.d.ts +1 -0
  100. package/dist/studio/index.d.ts.map +1 -1
  101. package/dist/studio/index.js +384 -247
  102. package/dist/studio/navigation.d.ts +57 -0
  103. package/dist/studio/navigation.d.ts.map +1 -0
  104. package/dist/studio/studio.css +1 -1
  105. package/dist/studio/utils/control-plane-api.d.ts +2 -0
  106. package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
  107. package/dist/studio/utils/control-plane-api.js +5 -1
  108. package/dist/studio-app/assets/{feltdb_wasm-BatwwogY.js → feltdb_wasm-BjM1ylL3.js} +1 -1
  109. package/dist/studio-app/assets/feltdb_wasm_bg-TseJn2Ax.wasm +0 -0
  110. package/dist/studio-app/assets/index-1wd0uKua.js +30 -0
  111. package/dist/studio-app/assets/{index-CiC8VY-a.css → index-vi21oEQa.css} +1 -1
  112. package/dist/studio-app/index.html +2 -2
  113. package/dist/transaction.d.ts +27 -11
  114. package/dist/transaction.d.ts.map +1 -1
  115. package/dist/transaction.js +20 -14
  116. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  117. package/dist/wasm/package.json +1 -1
  118. package/dist/workload.d.ts +31 -3
  119. package/dist/workload.d.ts.map +1 -1
  120. package/dist/workload.js +28 -3
  121. package/dist/workspace/local-development-authority.d.ts +19 -0
  122. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  123. package/dist/workspace/local-development-authority.js +335 -15
  124. package/package.json +1 -1
  125. package/dist/studio-app/assets/feltdb_wasm_bg-BX0newOr.wasm +0 -0
  126. 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 { success: false, error: String(error) };
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 { success: false, error: String(error) };
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
- const response = await fetch(`${this.url}/query`, {
290
- method: 'POST', headers: this.headers(true), body: JSON.stringify(query),
291
- });
292
- const body = await response.json();
293
- if (!response.ok) {
294
- const error = new Error(body.message || body.error || `authority query failed with HTTP ${response.status}`);
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
- 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();
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 { success: false, error: String(error) };
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 { success: false, error: String(error) };
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 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));
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 error = new Error(body.error || `transaction failed with HTTP ${response.status}`);
408
- error.status = response.status;
409
- if (body.code)
410
- 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;
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
- const response = await fetch(`${this.url}/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/cas`, {
567
- method: 'POST',
568
- headers: this.headers(true),
569
- body: JSON.stringify({
570
- expectedVersion: params.expectedVersion,
571
- expectedEpoch: params.expectedEpoch,
572
- expectedLeaseId: params.expectedLeaseId,
573
- rejectIfDiverged: params.rejectIfDiverged,
574
- value: parsed,
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
- const failure = await this.failure(response);
593
- 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);
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 { success: false, error: String(error) };
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 new Error((await this.failure(response)).error);
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 new Error((await this.failure(response)).error);
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 new Error((await this.failure(response)).error);
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 new Error((await this.failure(response)).error);
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 new Error((await this.failure(response)).error);
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 new Error((await this.failure(response)).error);
824
+ throw await this.refusal(response);
664
825
  return response.status === 204 ? undefined : response.json();
665
826
  }
666
- 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 = {}) {
667
834
  const separator = path.includes('?') ? '&' : '?';
668
- const context = this.applicationId
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}${context}`, {
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
- const mutating = !['GET', 'HEAD', 'OPTIONS'].includes((init.method ?? 'GET').toUpperCase());
684
- 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());
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 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);
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 controller = new AbortController();
754
- this.connectionState = 'connecting';
755
- void this.consumeEvents(controller, callback);
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
- controller.abort();
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
- 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;
853
1066
  try {
854
- const body = await response.json();
855
- if (body.error)
856
- detail = body.error;
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
- 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;
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"}