@riffdb/client 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -7,8 +7,8 @@ compiler IDs, storage keys, or an authorization bypass.
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- npm install --save-dev @riffdb/cli@0.1.0
11
- npm install @riffdb/client@0.1.0
10
+ npm install --save-dev @riffdb/cli@0.2.2
11
+ npm install @riffdb/client@0.2.2
12
12
  ```
13
13
 
14
14
  ## Four-step application story
@@ -40,6 +40,6 @@ response must be resolved with the same idempotency key.
40
40
 
41
41
  ## Version compatibility
42
42
 
43
- This is a 0.1.x pre-1.0 package. It carries the exact durable-format statement
43
+ This is a 0.2.x pre-1.0 package. It carries the exact durable-format statement
44
44
  from `release/durable-format-manifest-v1.json`; downgrade is unsupported and a
45
45
  breaking epoch requires symbolic export/reimport. Sealed and explicitly offline bundles retain vendored @riffdb/application, riffdb-application, riffdb.dev/application, and the source-contained Rust SDK.
package/dist/driver.d.ts CHANGED
@@ -59,6 +59,18 @@ export interface ExactMoneyValue<Currency extends string = string> {
59
59
  export declare function exactDecimal(value: string, precision: number, scale: number): ExactDecimalValue;
60
60
  export declare function exactMoney<const Currency extends string>(currency: Currency, value: string): ExactMoneyValue<Currency>;
61
61
  export interface DriverApplicationIdentity {
62
+ readonly applicationManifestHash?: string;
63
+ readonly operationCatalogHash?: string;
64
+ readonly contractLineage?: string;
65
+ readonly contractVersion?: bigint;
66
+ readonly contractBundleHash?: string;
67
+ readonly database?: string;
68
+ readonly role?: string;
69
+ readonly roleDefinitionHash?: string;
70
+ readonly remoteIdentityHash?: string;
71
+ readonly tenant?: string;
72
+ }
73
+ export interface ResolvedDriverApplicationIdentity {
62
74
  readonly applicationManifestHash: string;
63
75
  readonly operationCatalogHash: string;
64
76
  readonly contractLineage: string;
@@ -72,8 +84,18 @@ export interface DriverApplicationIdentity {
72
84
  }
73
85
  export interface DriverApplicationTransportOptions {
74
86
  readonly socketPath: string;
75
- readonly identity: DriverApplicationIdentity;
87
+ readonly identity?: DriverApplicationIdentity;
88
+ readonly tenant?: string;
89
+ }
90
+ export interface DriverConnectFromEnvOptions {
91
+ readonly socketPath?: string;
92
+ readonly identity?: Partial<DriverApplicationIdentity>;
93
+ readonly role?: string;
94
+ readonly database?: string;
76
95
  readonly tenant?: string;
96
+ readonly prefix?: string;
97
+ readonly envPrefix?: string;
98
+ readonly env?: Readonly<Record<string, string | undefined>>;
77
99
  }
78
100
  export interface DriverOperation {
79
101
  readonly name: string;
@@ -163,9 +185,13 @@ export declare class DriverApplicationTransport {
163
185
  private constructor();
164
186
  /** Returns the bound tenant identifier when this session is tenant-scoped. */
165
187
  get tenant(): string | undefined;
188
+ /** Returns the resolved application identity verified during driver handshake. */
189
+ get identity(): ResolvedDriverApplicationIdentity;
166
190
  static connect(options: DriverApplicationTransportOptions): Promise<DriverApplicationTransport>;
191
+ static connectFromEnv(options?: DriverConnectFromEnvOptions): Promise<DriverApplicationTransport>;
167
192
  invoke(operation: DriverOperation, input: Readonly<Record<string, DriverValue>>, options?: DriverInvokeOptions): Promise<DriverResult>;
168
193
  batch(operation: DriverOperation, items: ReadonlyArray<Readonly<Record<string, DriverValue>>>, concurrency: number, checkpoint: number, options?: DriverInvokeOptions): Promise<DriverBatchResult>;
169
194
  /** Stops new work, sends a clean local EOF, and rejects unfinished waits. */
170
195
  shutdown(): Promise<void>;
171
196
  }
197
+ export declare function resolveTransportOptionsFromEnv(options?: DriverConnectFromEnvOptions): DriverApplicationTransportOptions;
package/dist/driver.js CHANGED
@@ -61,28 +61,62 @@ export class DriverApplicationError extends Error {
61
61
  * surface. Those semantics remain owned by the Rust host.
62
62
  */
63
63
  export class DriverApplicationTransport {
64
+ #socketPath;
64
65
  #socket;
65
66
  #identity;
67
+ #resolvedIdentity;
66
68
  #tenant;
67
69
  #requestPrefix;
68
70
  #pending = new Map();
69
- #input = Buffer.alloc(0);
71
+ #inputChunks = [];
72
+ #inputLength = 0;
70
73
  #nextRequest = 1;
71
74
  #closed = false;
72
- constructor(socket, identity, tenant) {
75
+ #disconnected = false;
76
+ #reconnecting;
77
+ constructor(socketPath, socket, identity, tenant) {
78
+ this.#socketPath = socketPath;
73
79
  this.#socket = socket;
74
80
  this.#identity = identity;
75
81
  this.#tenant = tenant;
76
82
  nextSession = nextSession === Number.MAX_SAFE_INTEGER ? 1 : nextSession + 1;
77
83
  this.#requestPrefix = `ts.${process.pid}.${nextSession}`;
78
- socket.on("data", (chunk) => this.#receive(chunk));
79
- socket.on("error", () => this.#fail(new Error("RiffDB driver session failed")));
80
- socket.on("close", () => this.#fail(new Error("RiffDB driver session closed")));
84
+ this.#attachSocket(socket);
85
+ }
86
+ #attachSocket(socket) {
87
+ socket.on("data", (chunk) => {
88
+ if (this.#socket !== socket)
89
+ return;
90
+ this.#receive(chunk);
91
+ });
92
+ socket.on("error", () => {
93
+ if (this.#socket !== socket)
94
+ return;
95
+ this.#disconnected = true;
96
+ this.#inputChunks.length = 0;
97
+ this.#inputLength = 0;
98
+ this.#fail(new Error("RiffDB driver session failed"));
99
+ });
100
+ socket.on("close", () => {
101
+ if (this.#socket !== socket)
102
+ return;
103
+ this.#disconnected = true;
104
+ this.#inputChunks.length = 0;
105
+ this.#inputLength = 0;
106
+ this.#fail(new Error("RiffDB driver session closed"));
107
+ });
81
108
  }
82
109
  /** Returns the bound tenant identifier when this session is tenant-scoped. */
83
110
  get tenant() {
84
111
  return this.#tenant;
85
112
  }
113
+ /** Returns the resolved application identity verified during driver handshake. */
114
+ get identity() {
115
+ if (this.#resolvedIdentity === undefined) {
116
+ throw new Error("RiffDB driver session has not completed handshake");
117
+ }
118
+ return this.#resolvedIdentity;
119
+ }
86
120
  static async connect(options) {
87
121
  const tenant = resolveTransportTenant(options);
88
122
  validateTransportOptions(options);
@@ -92,7 +126,7 @@ export class DriverApplicationTransport {
92
126
  socket.once("connect", () => { socket.off("error", onError); resolve(); });
93
127
  socket.once("error", onError);
94
128
  });
95
- const transport = new DriverApplicationTransport(socket, options.identity, tenant);
129
+ const transport = new DriverApplicationTransport(options.socketPath, socket, options.identity ?? {}, tenant);
96
130
  try {
97
131
  await transport.#handshake();
98
132
  return transport;
@@ -102,9 +136,18 @@ export class DriverApplicationTransport {
102
136
  throw error;
103
137
  }
104
138
  }
139
+ static async connectFromEnv(options = {}) {
140
+ return DriverApplicationTransport.connect(resolveTransportOptionsFromEnv(options));
141
+ }
105
142
  async invoke(operation, input, options = {}) {
106
143
  validateOperation(operation);
107
144
  validateDriverRecord(input);
145
+ const lowered = lowerOptions(options);
146
+ if (options.signal?.aborted)
147
+ throw new Error("RiffDB driver request aborted");
148
+ if (this.#disconnected || this.#socket.destroyed) {
149
+ await this.#ensureConnected();
150
+ }
108
151
  const requestId = this.#requestId("invoke");
109
152
  const response = await this.#request({
110
153
  type: "invoke",
@@ -112,7 +155,7 @@ export class DriverApplicationTransport {
112
155
  operation: operation.name,
113
156
  input_schema_hash: operation.inputSchemaHash,
114
157
  input,
115
- options: lowerOptions(options),
158
+ options: lowered,
116
159
  }, requestId, options.signal);
117
160
  return decodeResult(response);
118
161
  }
@@ -127,6 +170,12 @@ export class DriverApplicationTransport {
127
170
  }
128
171
  for (const input of items)
129
172
  validateDriverRecord(input);
173
+ const lowered = lowerOptions(options);
174
+ if (options.signal?.aborted)
175
+ throw new Error("RiffDB driver request aborted");
176
+ if (this.#disconnected || this.#socket.destroyed) {
177
+ await this.#ensureConnected();
178
+ }
130
179
  const requestId = this.#requestId("batch");
131
180
  const response = await this.#request({
132
181
  type: "batch",
@@ -136,15 +185,16 @@ export class DriverApplicationTransport {
136
185
  items,
137
186
  concurrency,
138
187
  checkpoint,
139
- options: lowerOptions(options),
188
+ options: lowered,
140
189
  }, requestId, options.signal);
141
- return decodeBatchResult(response);
190
+ return decodeBatchResult(response, items.length, checkpoint);
142
191
  }
143
192
  /** Stops new work, sends a clean local EOF, and rejects unfinished waits. */
144
193
  async shutdown() {
145
194
  if (this.#closed)
146
195
  return;
147
196
  this.#closed = true;
197
+ this.#disconnected = true;
148
198
  this.#fail(new Error("RiffDB driver session closed"));
149
199
  await new Promise((resolve) => {
150
200
  if (this.#socket.destroyed) {
@@ -154,38 +204,121 @@ export class DriverApplicationTransport {
154
204
  this.#socket.end(resolve);
155
205
  });
156
206
  }
207
+ async #ensureConnected() {
208
+ if (this.#closed)
209
+ throw new Error("RiffDB driver session closed");
210
+ if (!this.#disconnected && !this.#socket.destroyed)
211
+ return;
212
+ if (this.#reconnecting !== undefined) {
213
+ await this.#reconnecting;
214
+ return;
215
+ }
216
+ const task = (async () => {
217
+ const socket = createConnection({ path: this.#socketPath });
218
+ await new Promise((resolve, reject) => {
219
+ const onError = () => reject(new Error("RiffDB driver session failed"));
220
+ socket.once("connect", () => { socket.off("error", onError); resolve(); });
221
+ socket.once("error", onError);
222
+ });
223
+ if (this.#closed) {
224
+ socket.destroy();
225
+ throw new Error("RiffDB driver session closed");
226
+ }
227
+ this.#socket = socket;
228
+ this.#inputChunks.length = 0;
229
+ this.#inputLength = 0;
230
+ this.#disconnected = false;
231
+ this.#attachSocket(socket);
232
+ try {
233
+ await this.#handshake();
234
+ }
235
+ catch (error) {
236
+ this.#disconnected = true;
237
+ socket.destroy();
238
+ throw error;
239
+ }
240
+ })();
241
+ this.#reconnecting = task;
242
+ try {
243
+ await task;
244
+ }
245
+ finally {
246
+ if (this.#reconnecting === task) {
247
+ this.#reconnecting = undefined;
248
+ }
249
+ }
250
+ }
157
251
  async #handshake() {
158
252
  const requestId = this.#requestId("handshake");
159
253
  const identity = this.#identity;
254
+ const manifestHash = identity.applicationManifestHash ?? "";
255
+ const catalogHash = identity.operationCatalogHash ?? "";
256
+ const contractLineage = identity.contractLineage ?? "";
257
+ const contractVersion = identity.contractVersion ?? 0n;
258
+ const contractBundleHash = identity.contractBundleHash ?? "";
259
+ const database = identity.database ?? "";
260
+ const role = identity.role ?? "";
261
+ const roleDefinitionHash = identity.roleDefinitionHash ?? "";
262
+ const remoteIdentityHash = identity.remoteIdentityHash ?? "";
160
263
  const response = await this.#request({
161
264
  type: "handshake",
162
265
  request_id: requestId,
163
266
  protocol_version: DRIVER_PROTOCOL_VERSION,
164
- application_manifest_hash: identity.applicationManifestHash,
165
- operation_catalog_hash: identity.operationCatalogHash,
166
- contract_lineage: identity.contractLineage,
167
- contract_version: identity.contractVersion,
168
- contract_bundle_hash: identity.contractBundleHash,
267
+ application_manifest_hash: manifestHash,
268
+ operation_catalog_hash: catalogHash,
269
+ contract_lineage: contractLineage,
270
+ contract_version: contractVersion,
271
+ contract_bundle_hash: contractBundleHash,
169
272
  value_registry_hash: DRIVER_VALUE_REGISTRY_HASH,
170
273
  error_registry_hash: DRIVER_ERROR_REGISTRY_HASH,
171
- database: identity.database,
172
- role: identity.role,
173
- role_definition_hash: identity.roleDefinitionHash,
174
- remote_identity_hash: identity.remoteIdentityHash,
274
+ database,
275
+ role,
276
+ role_definition_hash: roleDefinitionHash,
277
+ remote_identity_hash: remoteIdentityHash,
175
278
  }, requestId);
279
+ const resolvedContractVersion = requiredPositiveBigInt(response.contract_version);
176
280
  if (response.type !== "handshake"
177
281
  || response.protocol_version !== DRIVER_PROTOCOL_VERSION
178
- || response.application_manifest_hash !== identity.applicationManifestHash
179
- || response.operation_catalog_hash !== identity.operationCatalogHash
180
- || response.contract_lineage !== identity.contractLineage
181
- || requiredPositiveBigInt(response.contract_version) !== identity.contractVersion
182
- || response.contract_bundle_hash !== identity.contractBundleHash
183
- || response.database !== identity.database
184
- || response.role !== identity.role
185
- || response.role_definition_hash !== identity.roleDefinitionHash
186
- || response.remote_identity_hash !== identity.remoteIdentityHash) {
282
+ || typeof response.application_manifest_hash !== "string"
283
+ || !HASH.test(response.application_manifest_hash)
284
+ || (manifestHash !== "" && response.application_manifest_hash !== manifestHash)
285
+ || typeof response.operation_catalog_hash !== "string"
286
+ || !HASH.test(response.operation_catalog_hash)
287
+ || (catalogHash !== "" && response.operation_catalog_hash !== catalogHash)
288
+ || typeof response.contract_lineage !== "string"
289
+ || !SYMBOL.test(response.contract_lineage)
290
+ || (contractLineage !== "" && response.contract_lineage !== contractLineage)
291
+ || (contractVersion !== 0n && resolvedContractVersion !== contractVersion)
292
+ || typeof response.contract_bundle_hash !== "string"
293
+ || !HASH.test(response.contract_bundle_hash)
294
+ || (contractBundleHash !== "" && response.contract_bundle_hash !== contractBundleHash)
295
+ || typeof response.database !== "string"
296
+ || !SYMBOL.test(response.database)
297
+ || (database !== "" && response.database !== database)
298
+ || typeof response.role !== "string"
299
+ || !SYMBOL.test(response.role)
300
+ || (role !== "" && response.role !== role)
301
+ || typeof response.role_definition_hash !== "string"
302
+ || !HASH.test(response.role_definition_hash)
303
+ || (roleDefinitionHash !== "" && response.role_definition_hash !== roleDefinitionHash)
304
+ || typeof response.remote_identity_hash !== "string"
305
+ || !HASH.test(response.remote_identity_hash)
306
+ || (remoteIdentityHash !== "" && response.remote_identity_hash !== remoteIdentityHash)) {
187
307
  throw new Error("RiffDB driver identity mismatch");
188
308
  }
309
+ this.#resolvedIdentity = {
310
+ applicationManifestHash: response.application_manifest_hash,
311
+ operationCatalogHash: response.operation_catalog_hash,
312
+ contractLineage: response.contract_lineage,
313
+ contractVersion: resolvedContractVersion,
314
+ contractBundleHash: response.contract_bundle_hash,
315
+ database: response.database,
316
+ role: response.role,
317
+ roleDefinitionHash: response.role_definition_hash,
318
+ remoteIdentityHash: response.remote_identity_hash,
319
+ ...(this.#tenant === undefined ? {} : { tenant: this.#tenant }),
320
+ };
321
+ this.#identity = this.#resolvedIdentity;
189
322
  }
190
323
  async #request(request, requestId, signal) {
191
324
  if (this.#closed || this.#socket.destroyed)
@@ -230,31 +363,40 @@ export class DriverApplicationTransport {
230
363
  }
231
364
  }
232
365
  #receive(chunk) {
233
- if (this.#closed)
366
+ if (this.#closed || chunk.length === 0)
234
367
  return;
235
- this.#input = Buffer.concat([this.#input, chunk]);
236
- while (this.#input.length >= 4) {
237
- const length = this.#input.readUInt32BE(0);
368
+ this.#inputChunks.push(chunk);
369
+ this.#inputLength += chunk.length;
370
+ while (this.#inputLength >= 4) {
371
+ const length = this.#peekFrameLength();
238
372
  if (length < 2 || length > MAX_FRAME_BYTES) {
373
+ this.#closed = true;
374
+ this.#inputChunks.length = 0;
375
+ this.#inputLength = 0;
239
376
  this.#fail(new Error("RiffDB driver returned an invalid frame"));
240
377
  this.#socket.destroy();
241
378
  return;
242
379
  }
243
- if (this.#input.length < length + 4)
380
+ if (this.#inputLength < length + 4)
244
381
  return;
245
- const body = this.#input.subarray(4, length + 4);
246
- this.#input = this.#input.subarray(length + 4);
382
+ const body = this.#consumeFrame(length);
247
383
  let response;
248
384
  try {
249
385
  response = exactObject(decodeJson(UTF8_DECODER.decode(body)));
250
386
  }
251
387
  catch {
388
+ this.#closed = true;
389
+ this.#inputChunks.length = 0;
390
+ this.#inputLength = 0;
252
391
  this.#fail(new Error("RiffDB driver returned an invalid message"));
253
392
  this.#socket.destroy();
254
393
  return;
255
394
  }
256
395
  const requestId = response.request_id;
257
396
  if (typeof requestId !== "string" || !REQUEST_ID.test(requestId)) {
397
+ this.#closed = true;
398
+ this.#inputChunks.length = 0;
399
+ this.#inputLength = 0;
258
400
  this.#fail(new Error("RiffDB driver returned an invalid request identity"));
259
401
  this.#socket.destroy();
260
402
  return;
@@ -266,6 +408,80 @@ export class DriverApplicationTransport {
266
408
  pending.resolve(response);
267
409
  }
268
410
  }
411
+ #peekFrameLength() {
412
+ const first = this.#inputChunks[0];
413
+ if (first.length >= 4) {
414
+ return first.readUInt32BE(0);
415
+ }
416
+ let b0 = 0;
417
+ let b1 = 0;
418
+ let b2 = 0;
419
+ let b3 = 0;
420
+ let idx = 0;
421
+ outer: for (const chunk of this.#inputChunks) {
422
+ for (let i = 0; i < chunk.length; i += 1) {
423
+ const byte = chunk[i];
424
+ if (idx === 0)
425
+ b0 = byte;
426
+ else if (idx === 1)
427
+ b1 = byte;
428
+ else if (idx === 2)
429
+ b2 = byte;
430
+ else {
431
+ b3 = byte;
432
+ break outer;
433
+ }
434
+ idx += 1;
435
+ }
436
+ }
437
+ return ((b0 << 24) | (b1 << 16) | (b2 << 8) | b3) >>> 0;
438
+ }
439
+ #consumeFrame(bodyLength) {
440
+ this.#skipBytes(4);
441
+ const first = this.#inputChunks[0];
442
+ if (first.length >= bodyLength) {
443
+ const body = first.subarray(0, bodyLength);
444
+ if (first.length === bodyLength) {
445
+ this.#inputChunks.shift();
446
+ }
447
+ else {
448
+ this.#inputChunks[0] = first.subarray(bodyLength);
449
+ }
450
+ this.#inputLength -= bodyLength;
451
+ return body;
452
+ }
453
+ const body = Buffer.allocUnsafe(bodyLength);
454
+ let offset = 0;
455
+ while (offset < bodyLength) {
456
+ const chunk = this.#inputChunks[0];
457
+ const take = Math.min(chunk.length, bodyLength - offset);
458
+ chunk.copy(body, offset, 0, take);
459
+ offset += take;
460
+ if (take === chunk.length) {
461
+ this.#inputChunks.shift();
462
+ }
463
+ else {
464
+ this.#inputChunks[0] = chunk.subarray(take);
465
+ }
466
+ }
467
+ this.#inputLength -= bodyLength;
468
+ return body;
469
+ }
470
+ #skipBytes(count) {
471
+ let remaining = count;
472
+ while (remaining > 0) {
473
+ const chunk = this.#inputChunks[0];
474
+ if (chunk.length <= remaining) {
475
+ remaining -= chunk.length;
476
+ this.#inputChunks.shift();
477
+ }
478
+ else {
479
+ this.#inputChunks[0] = chunk.subarray(remaining);
480
+ remaining = 0;
481
+ }
482
+ }
483
+ this.#inputLength -= count;
484
+ }
269
485
  #fail(error) {
270
486
  for (const pending of this.#pending.values())
271
487
  pending.reject(error);
@@ -675,20 +891,30 @@ function decodeResult(response) {
675
891
  ...(cursor === undefined ? {} : { cursor }),
676
892
  };
677
893
  }
678
- function decodeBatchResult(response) {
894
+ function decodeBatchResult(response, expectedTotal, requestedCheckpoint) {
679
895
  if (response.type !== "batch_result" || !Array.isArray(response.items)
680
896
  || response.items.length > MAX_COLLECTION_ITEMS) {
681
897
  throw new Error("RiffDB driver returned an invalid batch result");
682
898
  }
683
899
  const checkpoint = boundedInteger(response.checkpoint, 0, MAX_COLLECTION_ITEMS);
684
900
  const total = boundedInteger(response.total, 1, MAX_COLLECTION_ITEMS);
685
- if (checkpoint > total)
901
+ if (total !== expectedTotal || checkpoint < requestedCheckpoint || checkpoint > total
902
+ || response.items.length !== total - requestedCheckpoint) {
686
903
  throw new Error("RiffDB driver returned an invalid batch result");
904
+ }
905
+ const seenIndices = new Set();
687
906
  const items = response.items.map((raw) => {
688
907
  const item = exactObject(raw);
689
- const index = boundedInteger(item.index, 0, total - 1);
908
+ const index = boundedInteger(item.index, requestedCheckpoint, total - 1);
909
+ if (seenIndices.has(index)) {
910
+ throw new Error("RiffDB driver returned an invalid batch item");
911
+ }
912
+ seenIndices.add(index);
690
913
  const outcome = exactObject(item.outcome);
691
914
  if (outcome.type === "result") {
915
+ if (index === checkpoint) {
916
+ throw new Error("RiffDB driver returned an invalid batch result");
917
+ }
692
918
  const value = validateDriverValue(outcome.value, 0);
693
919
  const commitSequence = optionalPositiveBigInt(outcome.commit_sequence);
694
920
  const outcomeUri = optionalBoundedString(outcome.outcome_uri, 16_384);
@@ -704,8 +930,12 @@ function decodeBatchResult(response) {
704
930
  },
705
931
  };
706
932
  }
707
- if (outcome.type === "error")
933
+ if (outcome.type === "error") {
934
+ if (index < checkpoint) {
935
+ throw new Error("RiffDB driver returned an invalid batch result");
936
+ }
708
937
  return { index, error: decodeError(outcome) };
938
+ }
709
939
  throw new Error("RiffDB driver returned an invalid batch item");
710
940
  });
711
941
  return { items, checkpoint, total };
@@ -740,9 +970,11 @@ function decodeError(response) {
740
970
  ...(incidentId === undefined ? {} : { incidentId }),
741
971
  });
742
972
  }
973
+ const ENV_RESOLVED = Symbol("riffdb.envResolved");
743
974
  function resolveTransportTenant(options) {
744
- const envTenant = process.env.RIFFDB_TENANT;
745
- const candidate = options.tenant ?? options.identity.tenant ?? (envTenant && envTenant.length > 0 ? envTenant : undefined);
975
+ const allowEnvFallback = !(ENV_RESOLVED in options);
976
+ const envTenant = allowEnvFallback ? process.env.RIFFDB_TENANT : undefined;
977
+ const candidate = options.tenant ?? options.identity?.tenant ?? (envTenant && envTenant.length > 0 ? envTenant : undefined);
746
978
  if (candidate === undefined)
747
979
  return undefined;
748
980
  if (typeof candidate !== "string" || candidate.length < 1 || candidate.length > 128 || !/^[\x21-\x7e]+$/.test(candidate)) {
@@ -750,21 +982,103 @@ function resolveTransportTenant(options) {
750
982
  }
751
983
  return candidate;
752
984
  }
985
+ export function resolveTransportOptionsFromEnv(options = {}) {
986
+ if (options.prefix !== undefined &&
987
+ options.envPrefix !== undefined &&
988
+ options.prefix !== options.envPrefix) {
989
+ throw new Error("conflicting RiffDB environment prefixes");
990
+ }
991
+ const rawPrefix = options.prefix ?? options.envPrefix;
992
+ if (rawPrefix !== undefined &&
993
+ (typeof rawPrefix !== "string" ||
994
+ rawPrefix.trim().length === 0 ||
995
+ !/^[A-Za-z_][A-Za-z0-9_]*$/.test(rawPrefix))) {
996
+ throw new Error("invalid RiffDB environment prefix");
997
+ }
998
+ const normalizedPrefix = rawPrefix === undefined
999
+ ? "RIFFDB_"
1000
+ : rawPrefix.endsWith("_")
1001
+ ? rawPrefix
1002
+ : `${rawPrefix}_`;
1003
+ const candidatePrefixes = normalizedPrefix === "RIFFDB_" || normalizedPrefix.endsWith("RIFFDB_")
1004
+ ? [normalizedPrefix]
1005
+ : [`${normalizedPrefix}RIFFDB_`, normalizedPrefix];
1006
+ const envSource = options.env ?? process.env;
1007
+ const readEnv = (suffix) => {
1008
+ for (const candidatePrefix of candidatePrefixes) {
1009
+ const raw = envSource[`${candidatePrefix}${suffix}`];
1010
+ if (raw !== undefined && raw.trim().length > 0) {
1011
+ return raw.trim();
1012
+ }
1013
+ }
1014
+ return undefined;
1015
+ };
1016
+ const socketPath = options.socketPath ?? readEnv("DRIVER_SOCKET") ?? "";
1017
+ const base = options.identity ?? {};
1018
+ const contractLineage = base.contractLineage ?? readEnv("LINEAGE") ?? "";
1019
+ const rawVersion = base.contractVersion ?? readEnv("CONTRACT_VERSION");
1020
+ let contractVersion = 0n;
1021
+ if (typeof rawVersion === "bigint") {
1022
+ contractVersion = rawVersion;
1023
+ }
1024
+ else if (typeof rawVersion === "string") {
1025
+ if (!/^[1-9][0-9]*$/.test(rawVersion)) {
1026
+ throw new Error("invalid RiffDB driver identity");
1027
+ }
1028
+ contractVersion = BigInt(rawVersion);
1029
+ }
1030
+ const contractBundleHash = base.contractBundleHash ?? readEnv("BUNDLE_HASH") ?? "";
1031
+ const role = options.role ?? base.role ?? readEnv("ROLE") ?? "";
1032
+ const database = options.database ?? base.database ?? readEnv("DATABASE");
1033
+ const applicationManifestHash = base.applicationManifestHash ?? readEnv("MANIFEST_HASH");
1034
+ const operationCatalogHash = base.operationCatalogHash ?? readEnv("CATALOG_HASH");
1035
+ const roleDefinitionHash = base.roleDefinitionHash ?? readEnv("ROLE_HASH");
1036
+ const remoteIdentityHash = base.remoteIdentityHash ?? readEnv("REMOTE_HASH");
1037
+ const tenant = options.tenant ?? base.tenant ?? readEnv("TENANT");
1038
+ const identity = {
1039
+ contractLineage,
1040
+ contractVersion,
1041
+ contractBundleHash,
1042
+ role,
1043
+ ...(database !== undefined && database.length > 0 ? { database } : {}),
1044
+ ...(applicationManifestHash !== undefined && applicationManifestHash.length > 0 ? { applicationManifestHash } : {}),
1045
+ ...(operationCatalogHash !== undefined && operationCatalogHash.length > 0 ? { operationCatalogHash } : {}),
1046
+ ...(roleDefinitionHash !== undefined && roleDefinitionHash.length > 0 ? { roleDefinitionHash } : {}),
1047
+ ...(remoteIdentityHash !== undefined && remoteIdentityHash.length > 0 ? { remoteIdentityHash } : {}),
1048
+ ...(tenant !== undefined && tenant.length > 0 ? { tenant } : {}),
1049
+ };
1050
+ const resolved = {
1051
+ socketPath,
1052
+ identity,
1053
+ ...(tenant !== undefined && tenant.length > 0 ? { tenant } : {}),
1054
+ };
1055
+ Object.defineProperty(resolved, ENV_RESOLVED, { value: true, enumerable: false });
1056
+ return resolved;
1057
+ }
753
1058
  function validateTransportOptions(options) {
754
1059
  if (!options.socketPath.startsWith("/") || options.socketPath.length > 4_096) {
755
1060
  throw new Error("invalid RiffDB driver socket path");
756
1061
  }
757
1062
  const identity = options.identity;
1063
+ if (identity === undefined)
1064
+ return;
758
1065
  for (const value of [
1066
+ identity.contractBundleHash,
759
1067
  identity.applicationManifestHash, identity.operationCatalogHash,
760
- identity.contractBundleHash, identity.roleDefinitionHash, identity.remoteIdentityHash,
1068
+ identity.roleDefinitionHash, identity.remoteIdentityHash,
761
1069
  ]) {
762
- if (!HASH.test(value))
1070
+ if (value !== undefined && value !== "" && !HASH.test(value)) {
763
1071
  throw new Error("invalid RiffDB driver identity");
1072
+ }
1073
+ }
1074
+ for (const value of [identity.database, identity.contractLineage, identity.role]) {
1075
+ if (value !== undefined && value !== "" && !SYMBOL.test(value)) {
1076
+ throw new Error("invalid RiffDB driver identity");
1077
+ }
764
1078
  }
765
- if (!SYMBOL.test(identity.contractLineage) || !SYMBOL.test(identity.database)
766
- || !SYMBOL.test(identity.role) || identity.contractVersion < 1n
767
- || identity.contractVersion > 18446744073709551615n) {
1079
+ if (identity.contractVersion !== undefined
1080
+ && identity.contractVersion !== 0n
1081
+ && (identity.contractVersion < 1n || identity.contractVersion > 18446744073709551615n)) {
768
1082
  throw new Error("invalid RiffDB driver identity");
769
1083
  }
770
1084
  }