@truenas/api-client 9.0.0 → 9.0.1

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
@@ -89,7 +89,9 @@ client.api.call('alert.dismiss', ['uuid-1']); // params checked against the me
89
89
  client.api.call('nope.nope'); // ✗ compile error
90
90
  ```
91
91
 
92
- A failed call errors with middleware's own message.
92
+ A failed call errors with a `TrueNasApiError`. Its `message` is middleware's own
93
+ reason, and the rest of the error is there too: `errname` (such as `EINVAL`),
94
+ and `data.extra`, where a validation error names the fields it rejected.
93
95
 
94
96
  ## Queries
95
97
 
@@ -116,7 +118,9 @@ client.api.job('pool.dataset.export_key', ['tank/encrypted']).subscribe(job => {
116
118
  ```
117
119
 
118
120
  The stream emits each state and completes when the job finishes. A failed job
119
- completes too, with `state` and `error` set.
121
+ completes too, with `state` and `error` set. A job method refused before it
122
+ starts a job, such as for a validation error, errors with a `TrueNasApiError`
123
+ instead, because there is no job to report.
120
124
 
121
125
  ## Events
122
126
 
@@ -129,7 +133,20 @@ client.api.events('app.query').subscribe(change => {
129
133
 
130
134
  Changes are typed per collection and discriminated on `msg`. One server
131
135
  subscription is shared by every subscriber and re-registered after each
132
- reconnect.
136
+ reconnect. When the last subscriber leaves, it is released with
137
+ `core.unsubscribe`.
138
+
139
+ Event sources take their arguments after the name, typed from the method's
140
+ `subscriptionParams`:
141
+
142
+ ```typescript
143
+ client.api.events('filesystem.file_tail_follow', { path: '/var/log/messages' })
144
+ .subscribe(line => append(line.fields.data));
145
+ ```
146
+
147
+ A source with different arguments is a separate subscription. If middleware
148
+ ends a source, the stream completes. If it rejects the arguments, the stream
149
+ errors with a `TrueNasApiError`.
133
150
 
134
151
  ## Working across versions
135
152
 
@@ -164,6 +181,17 @@ client.ops.smbStatus({ infoLevel: 'SESSIONS' });
164
181
  Per-version details, such as permissions that differ between releases, are in
165
182
  the `OperationMappings` reference.
166
183
 
184
+ Some behaviour differs between versions in ways no type can express, because a
185
+ default is not part of a TypeScript signature. One worth knowing: at v28.0.0,
186
+ `config.save()` with no arguments includes the secret seed in the backup, where
187
+ at v25.10 and v27.0 the same call left it out. That seed decrypts every
188
+ password, private key and API key the backup contains, so treat such a backup
189
+ as a secret; pass `{ secretseed: false }` for the old behaviour, bearing in mind
190
+ a backup saved without it cannot be uploaded. These are recorded as they are
191
+ found in
192
+ [`src/generated/MANIFEST.md`](https://github.com/truenas/api-client-ts/blob/main/src/generated/MANIFEST.md),
193
+ which is in the repository rather than the published package.
194
+
167
195
  ## Connection
168
196
 
169
197
  ```typescript
@@ -212,7 +240,8 @@ the client is doing.
212
240
 
213
241
  ## Errors
214
242
 
215
- - **Calls** error with middleware's message.
243
+ - **Calls, queries and job starts** error with `TrueNasApiError`. Its `message`
244
+ is middleware's reason, and `errname`, `code` and `data` carry the rest.
216
245
  - **Logins** error with `AuthError`. Check `code` against `AuthErrorCode`.
217
246
  - **`createTrueNasClient`** rejects with a `VersionDiscoveryError` subclass, such
218
247
  as `VersionTooOldError`, `VersionTooNewError` or
@@ -1,4 +1,4 @@
1
- import { BehaviorSubject, filter, switchMap, catchError, EMPTY, tap, map, finalize, take, Subject, distinctUntilChanged, of, startWith, concat, throwError, retry, race, timer, skip, takeUntil, shareReplay, interval, Observable, timeout, TimeoutError, toArray, from, defer, firstValueFrom, share, merge, takeWhile } from 'rxjs';
1
+ import { BehaviorSubject, filter, switchMap, catchError, EMPTY, tap, map, finalize, take, Subject, distinctUntilChanged, of, startWith, concat, throwError, retry, race, timer, skip, takeUntil, shareReplay, interval, Observable, timeout, TimeoutError, toArray, from, defer, firstValueFrom, share, ignoreElements, merge, takeWhile, skipWhile, pairwise } from 'rxjs';
2
2
  import { webSocket } from 'rxjs/webSocket';
3
3
  import { map as map$1, catchError as catchError$1, shareReplay as shareReplay$1 } from 'rxjs/operators';
4
4
 
@@ -28,6 +28,34 @@ function getApiErrorMessage(error, fallback = "API call failed") {
28
28
  return fallback;
29
29
  }
30
30
 
31
+ // src/errors/api.errors.ts
32
+ var TrueNasApiError = class _TrueNasApiError extends Error {
33
+ constructor(frame, method) {
34
+ super(getApiErrorMessage(frame, "API call failed"));
35
+ this.frame = frame;
36
+ this.method = method;
37
+ this.name = "TrueNasApiError";
38
+ Object.setPrototypeOf(this, _TrueNasApiError.prototype);
39
+ }
40
+ /** The JSON-RPC code: `-32001` for a refused call, others for a malformed one. */
41
+ get code() {
42
+ return this.frame.code;
43
+ }
44
+ /**
45
+ * The TrueNAS payload — `errname`, `reason`, `extra`, `trace`.
46
+ *
47
+ * `undefined` for the codes middleware sends without one, such as
48
+ * `-32601 "Method does not exist"`.
49
+ */
50
+ get data() {
51
+ return this.frame.data;
52
+ }
53
+ /** `data.errname`, e.g. `EINVAL`, or `undefined` when there is no payload. */
54
+ get errname() {
55
+ return this.frame.data?.errname;
56
+ }
57
+ };
58
+
31
59
  // src/types/job.type.ts
32
60
  var JobState = /* @__PURE__ */ ((JobState2) => {
33
61
  JobState2["Pending"] = "PENDING";
@@ -2222,6 +2250,19 @@ function createJsonRpcMessage(method, params) {
2222
2250
  };
2223
2251
  }
2224
2252
 
2253
+ // src/utils/subscription.utils.ts
2254
+ function subscriptionName(event, params) {
2255
+ return params === void 0 ? event : `${event}:${JSON.stringify(params, sortKeys)}`;
2256
+ }
2257
+ function sortKeys(_key, value) {
2258
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
2259
+ return value;
2260
+ }
2261
+ return Object.fromEntries(
2262
+ Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
2263
+ );
2264
+ }
2265
+
2225
2266
  // src/api/truenas-api.ts
2226
2267
  var EVENT_KINDS = ["added", "changed", "removed"];
2227
2268
  var TrueNasApi = class {
@@ -2231,8 +2272,13 @@ var TrueNasApi = class {
2231
2272
  /**
2232
2273
  * Stream of job events from websocket.
2233
2274
  * JSON-RPC 2.0 events have structure: { method: 'collection_update', params: { collection, fields, ... } }
2275
+ *
2276
+ * Reads `messages()` when first subscribed, not when this is built, as every
2277
+ * other verb does. A consumer that wraps the connection's `messages()` after
2278
+ * the client is created — a debug panel mixing in mocked answers — is
2279
+ * otherwise heard by `call` and `events` but not by job tracking.
2234
2280
  */
2235
- this.jobEvents = this.connection.messages().pipe(
2281
+ this.jobEvents = defer(() => this.connection.messages()).pipe(
2236
2282
  filter(
2237
2283
  (res) => res.method === "collection_update" && res.params?.collection === "core.get_jobs"
2238
2284
  ),
@@ -2241,11 +2287,20 @@ var TrueNasApi = class {
2241
2287
  share()
2242
2288
  );
2243
2289
  /**
2244
- * One shared stream per subscribed event name — see {@link events}.
2245
- * Keyed by name rather than by caller, because the subscription it stands
2246
- * for lives on the server and is per socket, not per caller.
2290
+ * One shared stream per subscription — see {@link events}. Keyed by the
2291
+ * name *with* its arguments: a tail of one file is not a tail of another.
2247
2292
  */
2248
2293
  this.eventStreams = /* @__PURE__ */ new Map();
2294
+ /**
2295
+ * Releases awaiting their `core.unsubscribe` reply, per name. Middleware
2296
+ * sends a source's release notice (`terminate`) before that reply, and a
2297
+ * notice names only the collection; so an error-free one arriving inside
2298
+ * that window belongs to the release, not to a later subscription. One per
2299
+ * window, which closes on the reply whether or not a notice came.
2300
+ */
2301
+ this.releasing = /* @__PURE__ */ new Map();
2302
+ /** The live subscription per name, told when the server ends it. */
2303
+ this.serverEnds = /* @__PURE__ */ new Map();
2249
2304
  this.initializeJobEventsSubscription();
2250
2305
  }
2251
2306
  /**
@@ -2283,11 +2338,7 @@ var TrueNasApi = class {
2283
2338
  withId(messageId),
2284
2339
  map((msg) => {
2285
2340
  if (msg.error) {
2286
- const errorMessage = getApiErrorMessage(
2287
- msg.error,
2288
- "API call failed"
2289
- );
2290
- throw new Error(errorMessage);
2341
+ throw new TrueNasApiError(msg.error, method);
2291
2342
  }
2292
2343
  return msg.result;
2293
2344
  }),
@@ -2343,18 +2394,29 @@ var TrueNasApi = class {
2343
2394
  * differs by version (v25.10 answers with the id, v27 with `null`), while
2344
2395
  * the event correlation holds for both.
2345
2396
  *
2346
- * @returns Observable that emits the job ID when received from websocket events
2397
+ * @returns the job's id; errors with a {@link TrueNasApiError} if the method is refused before a job starts
2347
2398
  */
2348
2399
  callAndGetJobId(method, ...params) {
2349
2400
  return defer(() => {
2350
2401
  const message = createJsonRpcMessage(method, params[0]);
2351
- const seen = this.jobEvents.pipe(
2402
+ const started = this.jobEvents.pipe(
2352
2403
  filter((job) => job.message_ids?.includes(message.id ?? "") ?? false),
2353
- map((job) => job.id),
2354
- take(1)
2404
+ map((job) => job.id)
2405
+ );
2406
+ const refused = this.connection.messages().pipe(
2407
+ withId(message.id ?? ""),
2408
+ map((reply) => {
2409
+ if (reply.error) {
2410
+ throw new TrueNasApiError(reply.error, method);
2411
+ }
2412
+ }),
2413
+ ignoreElements()
2355
2414
  );
2356
2415
  const sending = this.connection.send(message);
2357
- return seen.pipe(finalize(() => sending.unsubscribe()));
2416
+ return merge(started, refused).pipe(
2417
+ take(1),
2418
+ finalize(() => sending.unsubscribe())
2419
+ );
2358
2420
  });
2359
2421
  }
2360
2422
  /**
@@ -2373,41 +2435,118 @@ var TrueNasApi = class {
2373
2435
  switchMap((jobId) => this.trackJob(jobId))
2374
2436
  );
2375
2437
  }
2376
- /**
2377
- * Subscribe to a collection and emit its changes, discriminated on `msg`.
2378
- *
2379
- * ```typescript
2380
- * api.events('app.query').subscribe(event => {
2381
- * if (event.msg === 'removed') return drop(event.id);
2382
- * render(event.fields); // only reachable once narrowed
2383
- * });
2384
- * ```
2385
- *
2386
- * Narrow before reading `fields`: `removed` events almost never carry it.
2387
- * Sources taking subscribe-time arguments are excluded; see {@link EventName}.
2388
- */
2389
- events(event) {
2390
- const existing = this.eventStreams.get(event);
2438
+ events(event, params) {
2439
+ const name = subscriptionName(event, params);
2440
+ this.listenForEndNotices();
2441
+ const existing = this.eventStreams.get(name);
2391
2442
  if (existing) return existing;
2443
+ const forget = () => {
2444
+ if (this.eventStreams.get(name) === stream) this.eventStreams.delete(name);
2445
+ };
2392
2446
  const stream = defer(() => {
2393
- const resubscribe = this.authenticated.pipe(distinctUntilChanged(), filter(Boolean)).subscribe(() => {
2394
- this.connection.send(createJsonRpcMessage("core.subscribe", [event]));
2447
+ let subscriptionId;
2448
+ let released = false;
2449
+ let serverEnded = false;
2450
+ const ended = new Subject();
2451
+ this.serverEnds.set(name, ended);
2452
+ const subscribing = this.authenticated.pipe(distinctUntilChanged()).subscribe((isAuthenticated) => {
2453
+ subscriptionId = void 0;
2454
+ if (!isAuthenticated) return;
2455
+ const request = createJsonRpcMessage("core.subscribe", [name]);
2456
+ this.connection.messages().pipe(
2457
+ withId(request.id ?? ""),
2458
+ take(1),
2459
+ takeUntil(this.authenticated.pipe(filter((isUp) => !isUp)))
2460
+ ).subscribe((reply) => {
2461
+ if (typeof reply.result !== "string") return;
2462
+ if (!released) subscriptionId = reply.result;
2463
+ else if (!serverEnded) this.unsubscribe(name, reply.result);
2464
+ });
2465
+ this.connection.send(request);
2395
2466
  });
2396
- return this.connection.messages().pipe(
2397
- filter((res) => res.method === "collection_update"),
2398
- map((res) => res.params),
2467
+ const updates = this.connection.messages().pipe(
2399
2468
  filter(
2400
- (params) => params?.collection === event && EVENT_KINDS.includes(params.msg)
2469
+ (res) => res.method === "collection_update" && res.params?.collection === name
2401
2470
  ),
2471
+ map((res) => res.params),
2472
+ filter((params2) => EVENT_KINDS.includes(params2.msg)),
2473
+ // `merge` below ends only when both inputs do. The connection closing
2474
+ // for good ends this one; `ended` has to follow, or the stream never
2475
+ // completes and nothing tears down the resubscription.
2476
+ finalize(() => ended.complete())
2477
+ );
2478
+ const end = ended.pipe(
2479
+ map((notice) => {
2480
+ subscriptionId = void 0;
2481
+ serverEnded = true;
2482
+ if (notice.error) {
2483
+ throw new TrueNasApiError(
2484
+ { code: -32001, message: notice.error.reason, data: notice.error },
2485
+ name
2486
+ );
2487
+ }
2488
+ })
2489
+ );
2490
+ return merge(updates, end).pipe(
2491
+ takeWhile((change) => change !== void 0),
2402
2492
  // `collection` is the subscription the caller already named; what is
2403
2493
  // left is exactly the payload the directory declares, plus its tag.
2404
2494
  map(({ collection, ...change }) => change),
2405
- finalize(() => resubscribe.unsubscribe())
2495
+ // An ended stream stays ended (below), so the next `events()` call for
2496
+ // this name needs a new one.
2497
+ tap({ complete: forget, error: forget }),
2498
+ finalize(() => {
2499
+ released = true;
2500
+ subscribing.unsubscribe();
2501
+ if (this.serverEnds.get(name) === ended) this.serverEnds.delete(name);
2502
+ if (subscriptionId) this.unsubscribe(name, subscriptionId);
2503
+ })
2406
2504
  );
2407
- }).pipe(share({ resetOnRefCountZero: false, resetOnComplete: false }));
2408
- this.eventStreams.set(event, stream);
2505
+ }).pipe(
2506
+ share({
2507
+ resetOnRefCountZero: true,
2508
+ resetOnComplete: false,
2509
+ resetOnError: false
2510
+ })
2511
+ );
2512
+ this.eventStreams.set(name, stream);
2409
2513
  return stream;
2410
2514
  }
2515
+ /**
2516
+ * Release a subscription by the id `core.subscribe` answered with, holding
2517
+ * its notice window (see {@link releasing}) open until the reply.
2518
+ */
2519
+ unsubscribe(name, subscriptionId) {
2520
+ const release = { noticed: false };
2521
+ this.releasing.set(name, [...this.releasing.get(name) ?? [], release]);
2522
+ const request = createJsonRpcMessage("core.unsubscribe", [subscriptionId]);
2523
+ this.connection.messages().pipe(
2524
+ withId(request.id ?? ""),
2525
+ take(1),
2526
+ takeUntil(this.authenticated.pipe(filter((isUp) => !isUp))),
2527
+ finalize(() => {
2528
+ const open = (this.releasing.get(name) ?? []).filter((r) => r !== release);
2529
+ if (open.length) this.releasing.set(name, open);
2530
+ else this.releasing.delete(name);
2531
+ })
2532
+ ).subscribe();
2533
+ this.connection.send(request);
2534
+ }
2535
+ /**
2536
+ * Route each `notify_unsubscribed` once: an error-free one inside an open
2537
+ * release window is that release's, anything else ends the name's live
2538
+ * subscription. Installed on the first `events()` call, before any stream
2539
+ * listens.
2540
+ */
2541
+ listenForEndNotices() {
2542
+ if (this.endNotices) return;
2543
+ this.endNotices = this.connection.messages().pipe(filter((res) => res.method === "notify_unsubscribed")).subscribe((res) => {
2544
+ const notice = res.params;
2545
+ const release = notice.error ? void 0 : this.releasing.get(notice.collection)?.find((r) => !r.noticed);
2546
+ if (release) release.noticed = true;
2547
+ else this.serverEnds.get(notice.collection)?.next(notice);
2548
+ });
2549
+ }
2411
2550
  /**
2412
2551
  * Convenience wrapper for auth.generate_token API call.
2413
2552
  *
@@ -2440,12 +2579,10 @@ var TrueNasApi = class {
2440
2579
  filter((job) => job.id === jobId),
2441
2580
  map((job) => job)
2442
2581
  );
2443
- const read$ = this.dispatch("core.get_jobs", [
2444
- [["id", "=", jobId]]
2445
- ]).pipe(
2446
- map((jobs) => jobs[0]),
2447
- share()
2582
+ const readJob = () => this.dispatch("core.get_jobs", [[["id", "=", jobId]]]).pipe(
2583
+ map((jobs) => jobs[0])
2448
2584
  );
2585
+ const read$ = readJob().pipe(share());
2449
2586
  const missing$ = read$.pipe(
2450
2587
  filter((job) => job === void 0),
2451
2588
  takeUntil(updates$)
@@ -2454,10 +2591,29 @@ var TrueNasApi = class {
2454
2591
  filter((job) => job !== void 0),
2455
2592
  takeUntil(updates$)
2456
2593
  );
2457
- return merge(snapshot$, updates$).pipe(
2594
+ const reread$ = this.authenticated.pipe(
2595
+ distinctUntilChanged(),
2596
+ skipWhile((isUp) => !isUp),
2597
+ pairwise(),
2598
+ filter(([wasUp, isUp]) => !wasUp && isUp),
2599
+ switchMap(
2600
+ () => readJob().pipe(
2601
+ // Stale once events flow again, as the opening read is; they carry the end.
2602
+ takeUntil(updates$),
2603
+ // A failed read waits for the next reconnect.
2604
+ catchError(() => EMPTY)
2605
+ )
2606
+ ),
2607
+ share()
2608
+ );
2609
+ const gone$ = reread$.pipe(filter((job) => job === void 0));
2610
+ const rereadJob$ = reread$.pipe(
2611
+ filter((job) => job !== void 0)
2612
+ );
2613
+ return merge(snapshot$, updates$, rereadJob$).pipe(
2458
2614
  takeWhile((job) => !isJobFinished(job), true),
2459
2615
  // Include the final state
2460
- takeUntil(missing$)
2616
+ takeUntil(merge(missing$, gone$))
2461
2617
  );
2462
2618
  }
2463
2619
  /**
@@ -4356,6 +4512,6 @@ var UserRole = /* @__PURE__ */ ((UserRole2) => {
4356
4512
  return UserRole2;
4357
4513
  })(UserRole || {});
4358
4514
 
4359
- export { AppState, AuthError, AuthErrorCode, AuthResponseType, InvalidVersionResponseError, JobState, NoCompatibleVersionsError, SUPPORTED_API_VERSIONS, TrueNasApiClient, TrueNasApiClientV2510, TrueNasApiClientV27, TrueNasApiClientV28, TrueNasAuthMechanism, TrueNasAuthenticator, TrueNasConnection, UserRole, VersionCompatibility, VersionDiscovery, VersionDiscoveryError, VersionDiscoveryNetworkError, VersionDiscoveryTimeoutError, VersionEndpointNotFoundError, VersionTooNewError, VersionTooOldError, clientClassFor, consoleLogger, createTrueNasClient, getApiErrorMessage, getCloseMessage, isJobFinished, noopLogger, parseApiVersion, policyViolationCloseCode, v25_10_0_exports, v25_10_1_exports, v25_10_2_exports, v25_10_3_exports, v25_10_4_exports, v25_10_5_exports, v27_0_0_exports, v28_0_0_exports };
4360
- //# sourceMappingURL=chunk-LFNAVARU.js.map
4361
- //# sourceMappingURL=chunk-LFNAVARU.js.map
4515
+ export { AppState, AuthError, AuthErrorCode, AuthResponseType, InvalidVersionResponseError, JobState, NoCompatibleVersionsError, SUPPORTED_API_VERSIONS, TrueNasApiClient, TrueNasApiClientV2510, TrueNasApiClientV27, TrueNasApiClientV28, TrueNasApiError, TrueNasAuthMechanism, TrueNasAuthenticator, TrueNasConnection, UserRole, VersionCompatibility, VersionDiscovery, VersionDiscoveryError, VersionDiscoveryNetworkError, VersionDiscoveryTimeoutError, VersionEndpointNotFoundError, VersionTooNewError, VersionTooOldError, clientClassFor, consoleLogger, createTrueNasClient, getApiErrorMessage, getCloseMessage, isJobFinished, noopLogger, parseApiVersion, policyViolationCloseCode, subscriptionName, v25_10_0_exports, v25_10_1_exports, v25_10_2_exports, v25_10_3_exports, v25_10_4_exports, v25_10_5_exports, v27_0_0_exports, v28_0_0_exports };
4516
+ //# sourceMappingURL=chunk-K7BL6J3Z.js.map
4517
+ //# sourceMappingURL=chunk-K7BL6J3Z.js.map