@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.
@@ -30,6 +30,34 @@ function getApiErrorMessage(error, fallback = "API call failed") {
30
30
  return fallback;
31
31
  }
32
32
 
33
+ // src/errors/api.errors.ts
34
+ var TrueNasApiError = class _TrueNasApiError extends Error {
35
+ constructor(frame, method) {
36
+ super(getApiErrorMessage(frame, "API call failed"));
37
+ this.frame = frame;
38
+ this.method = method;
39
+ this.name = "TrueNasApiError";
40
+ Object.setPrototypeOf(this, _TrueNasApiError.prototype);
41
+ }
42
+ /** The JSON-RPC code: `-32001` for a refused call, others for a malformed one. */
43
+ get code() {
44
+ return this.frame.code;
45
+ }
46
+ /**
47
+ * The TrueNAS payload — `errname`, `reason`, `extra`, `trace`.
48
+ *
49
+ * `undefined` for the codes middleware sends without one, such as
50
+ * `-32601 "Method does not exist"`.
51
+ */
52
+ get data() {
53
+ return this.frame.data;
54
+ }
55
+ /** `data.errname`, e.g. `EINVAL`, or `undefined` when there is no payload. */
56
+ get errname() {
57
+ return this.frame.data?.errname;
58
+ }
59
+ };
60
+
33
61
  // src/types/job.type.ts
34
62
  var JobState = /* @__PURE__ */ ((JobState2) => {
35
63
  JobState2["Pending"] = "PENDING";
@@ -2224,6 +2252,19 @@ function createJsonRpcMessage(method, params) {
2224
2252
  };
2225
2253
  }
2226
2254
 
2255
+ // src/utils/subscription.utils.ts
2256
+ function subscriptionName(event, params) {
2257
+ return params === void 0 ? event : `${event}:${JSON.stringify(params, sortKeys)}`;
2258
+ }
2259
+ function sortKeys(_key, value) {
2260
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
2261
+ return value;
2262
+ }
2263
+ return Object.fromEntries(
2264
+ Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
2265
+ );
2266
+ }
2267
+
2227
2268
  // src/api/truenas-api.ts
2228
2269
  var EVENT_KINDS = ["added", "changed", "removed"];
2229
2270
  var TrueNasApi = class {
@@ -2233,8 +2274,13 @@ var TrueNasApi = class {
2233
2274
  /**
2234
2275
  * Stream of job events from websocket.
2235
2276
  * JSON-RPC 2.0 events have structure: { method: 'collection_update', params: { collection, fields, ... } }
2277
+ *
2278
+ * Reads `messages()` when first subscribed, not when this is built, as every
2279
+ * other verb does. A consumer that wraps the connection's `messages()` after
2280
+ * the client is created — a debug panel mixing in mocked answers — is
2281
+ * otherwise heard by `call` and `events` but not by job tracking.
2236
2282
  */
2237
- this.jobEvents = this.connection.messages().pipe(
2283
+ this.jobEvents = rxjs.defer(() => this.connection.messages()).pipe(
2238
2284
  rxjs.filter(
2239
2285
  (res) => res.method === "collection_update" && res.params?.collection === "core.get_jobs"
2240
2286
  ),
@@ -2243,11 +2289,20 @@ var TrueNasApi = class {
2243
2289
  rxjs.share()
2244
2290
  );
2245
2291
  /**
2246
- * One shared stream per subscribed event name — see {@link events}.
2247
- * Keyed by name rather than by caller, because the subscription it stands
2248
- * for lives on the server and is per socket, not per caller.
2292
+ * One shared stream per subscription — see {@link events}. Keyed by the
2293
+ * name *with* its arguments: a tail of one file is not a tail of another.
2249
2294
  */
2250
2295
  this.eventStreams = /* @__PURE__ */ new Map();
2296
+ /**
2297
+ * Releases awaiting their `core.unsubscribe` reply, per name. Middleware
2298
+ * sends a source's release notice (`terminate`) before that reply, and a
2299
+ * notice names only the collection; so an error-free one arriving inside
2300
+ * that window belongs to the release, not to a later subscription. One per
2301
+ * window, which closes on the reply whether or not a notice came.
2302
+ */
2303
+ this.releasing = /* @__PURE__ */ new Map();
2304
+ /** The live subscription per name, told when the server ends it. */
2305
+ this.serverEnds = /* @__PURE__ */ new Map();
2251
2306
  this.initializeJobEventsSubscription();
2252
2307
  }
2253
2308
  /**
@@ -2285,11 +2340,7 @@ var TrueNasApi = class {
2285
2340
  withId(messageId),
2286
2341
  rxjs.map((msg) => {
2287
2342
  if (msg.error) {
2288
- const errorMessage = getApiErrorMessage(
2289
- msg.error,
2290
- "API call failed"
2291
- );
2292
- throw new Error(errorMessage);
2343
+ throw new TrueNasApiError(msg.error, method);
2293
2344
  }
2294
2345
  return msg.result;
2295
2346
  }),
@@ -2345,18 +2396,29 @@ var TrueNasApi = class {
2345
2396
  * differs by version (v25.10 answers with the id, v27 with `null`), while
2346
2397
  * the event correlation holds for both.
2347
2398
  *
2348
- * @returns Observable that emits the job ID when received from websocket events
2399
+ * @returns the job's id; errors with a {@link TrueNasApiError} if the method is refused before a job starts
2349
2400
  */
2350
2401
  callAndGetJobId(method, ...params) {
2351
2402
  return rxjs.defer(() => {
2352
2403
  const message = createJsonRpcMessage(method, params[0]);
2353
- const seen = this.jobEvents.pipe(
2404
+ const started = this.jobEvents.pipe(
2354
2405
  rxjs.filter((job) => job.message_ids?.includes(message.id ?? "") ?? false),
2355
- rxjs.map((job) => job.id),
2356
- rxjs.take(1)
2406
+ rxjs.map((job) => job.id)
2407
+ );
2408
+ const refused = this.connection.messages().pipe(
2409
+ withId(message.id ?? ""),
2410
+ rxjs.map((reply) => {
2411
+ if (reply.error) {
2412
+ throw new TrueNasApiError(reply.error, method);
2413
+ }
2414
+ }),
2415
+ rxjs.ignoreElements()
2357
2416
  );
2358
2417
  const sending = this.connection.send(message);
2359
- return seen.pipe(rxjs.finalize(() => sending.unsubscribe()));
2418
+ return rxjs.merge(started, refused).pipe(
2419
+ rxjs.take(1),
2420
+ rxjs.finalize(() => sending.unsubscribe())
2421
+ );
2360
2422
  });
2361
2423
  }
2362
2424
  /**
@@ -2375,41 +2437,118 @@ var TrueNasApi = class {
2375
2437
  rxjs.switchMap((jobId) => this.trackJob(jobId))
2376
2438
  );
2377
2439
  }
2378
- /**
2379
- * Subscribe to a collection and emit its changes, discriminated on `msg`.
2380
- *
2381
- * ```typescript
2382
- * api.events('app.query').subscribe(event => {
2383
- * if (event.msg === 'removed') return drop(event.id);
2384
- * render(event.fields); // only reachable once narrowed
2385
- * });
2386
- * ```
2387
- *
2388
- * Narrow before reading `fields`: `removed` events almost never carry it.
2389
- * Sources taking subscribe-time arguments are excluded; see {@link EventName}.
2390
- */
2391
- events(event) {
2392
- const existing = this.eventStreams.get(event);
2440
+ events(event, params) {
2441
+ const name = subscriptionName(event, params);
2442
+ this.listenForEndNotices();
2443
+ const existing = this.eventStreams.get(name);
2393
2444
  if (existing) return existing;
2445
+ const forget = () => {
2446
+ if (this.eventStreams.get(name) === stream) this.eventStreams.delete(name);
2447
+ };
2394
2448
  const stream = rxjs.defer(() => {
2395
- const resubscribe = this.authenticated.pipe(rxjs.distinctUntilChanged(), rxjs.filter(Boolean)).subscribe(() => {
2396
- this.connection.send(createJsonRpcMessage("core.subscribe", [event]));
2449
+ let subscriptionId;
2450
+ let released = false;
2451
+ let serverEnded = false;
2452
+ const ended = new rxjs.Subject();
2453
+ this.serverEnds.set(name, ended);
2454
+ const subscribing = this.authenticated.pipe(rxjs.distinctUntilChanged()).subscribe((isAuthenticated) => {
2455
+ subscriptionId = void 0;
2456
+ if (!isAuthenticated) return;
2457
+ const request = createJsonRpcMessage("core.subscribe", [name]);
2458
+ this.connection.messages().pipe(
2459
+ withId(request.id ?? ""),
2460
+ rxjs.take(1),
2461
+ rxjs.takeUntil(this.authenticated.pipe(rxjs.filter((isUp) => !isUp)))
2462
+ ).subscribe((reply) => {
2463
+ if (typeof reply.result !== "string") return;
2464
+ if (!released) subscriptionId = reply.result;
2465
+ else if (!serverEnded) this.unsubscribe(name, reply.result);
2466
+ });
2467
+ this.connection.send(request);
2397
2468
  });
2398
- return this.connection.messages().pipe(
2399
- rxjs.filter((res) => res.method === "collection_update"),
2400
- rxjs.map((res) => res.params),
2469
+ const updates = this.connection.messages().pipe(
2401
2470
  rxjs.filter(
2402
- (params) => params?.collection === event && EVENT_KINDS.includes(params.msg)
2471
+ (res) => res.method === "collection_update" && res.params?.collection === name
2403
2472
  ),
2473
+ rxjs.map((res) => res.params),
2474
+ rxjs.filter((params2) => EVENT_KINDS.includes(params2.msg)),
2475
+ // `merge` below ends only when both inputs do. The connection closing
2476
+ // for good ends this one; `ended` has to follow, or the stream never
2477
+ // completes and nothing tears down the resubscription.
2478
+ rxjs.finalize(() => ended.complete())
2479
+ );
2480
+ const end = ended.pipe(
2481
+ rxjs.map((notice) => {
2482
+ subscriptionId = void 0;
2483
+ serverEnded = true;
2484
+ if (notice.error) {
2485
+ throw new TrueNasApiError(
2486
+ { code: -32001, message: notice.error.reason, data: notice.error },
2487
+ name
2488
+ );
2489
+ }
2490
+ })
2491
+ );
2492
+ return rxjs.merge(updates, end).pipe(
2493
+ rxjs.takeWhile((change) => change !== void 0),
2404
2494
  // `collection` is the subscription the caller already named; what is
2405
2495
  // left is exactly the payload the directory declares, plus its tag.
2406
2496
  rxjs.map(({ collection, ...change }) => change),
2407
- rxjs.finalize(() => resubscribe.unsubscribe())
2497
+ // An ended stream stays ended (below), so the next `events()` call for
2498
+ // this name needs a new one.
2499
+ rxjs.tap({ complete: forget, error: forget }),
2500
+ rxjs.finalize(() => {
2501
+ released = true;
2502
+ subscribing.unsubscribe();
2503
+ if (this.serverEnds.get(name) === ended) this.serverEnds.delete(name);
2504
+ if (subscriptionId) this.unsubscribe(name, subscriptionId);
2505
+ })
2408
2506
  );
2409
- }).pipe(rxjs.share({ resetOnRefCountZero: false, resetOnComplete: false }));
2410
- this.eventStreams.set(event, stream);
2507
+ }).pipe(
2508
+ rxjs.share({
2509
+ resetOnRefCountZero: true,
2510
+ resetOnComplete: false,
2511
+ resetOnError: false
2512
+ })
2513
+ );
2514
+ this.eventStreams.set(name, stream);
2411
2515
  return stream;
2412
2516
  }
2517
+ /**
2518
+ * Release a subscription by the id `core.subscribe` answered with, holding
2519
+ * its notice window (see {@link releasing}) open until the reply.
2520
+ */
2521
+ unsubscribe(name, subscriptionId) {
2522
+ const release = { noticed: false };
2523
+ this.releasing.set(name, [...this.releasing.get(name) ?? [], release]);
2524
+ const request = createJsonRpcMessage("core.unsubscribe", [subscriptionId]);
2525
+ this.connection.messages().pipe(
2526
+ withId(request.id ?? ""),
2527
+ rxjs.take(1),
2528
+ rxjs.takeUntil(this.authenticated.pipe(rxjs.filter((isUp) => !isUp))),
2529
+ rxjs.finalize(() => {
2530
+ const open = (this.releasing.get(name) ?? []).filter((r) => r !== release);
2531
+ if (open.length) this.releasing.set(name, open);
2532
+ else this.releasing.delete(name);
2533
+ })
2534
+ ).subscribe();
2535
+ this.connection.send(request);
2536
+ }
2537
+ /**
2538
+ * Route each `notify_unsubscribed` once: an error-free one inside an open
2539
+ * release window is that release's, anything else ends the name's live
2540
+ * subscription. Installed on the first `events()` call, before any stream
2541
+ * listens.
2542
+ */
2543
+ listenForEndNotices() {
2544
+ if (this.endNotices) return;
2545
+ this.endNotices = this.connection.messages().pipe(rxjs.filter((res) => res.method === "notify_unsubscribed")).subscribe((res) => {
2546
+ const notice = res.params;
2547
+ const release = notice.error ? void 0 : this.releasing.get(notice.collection)?.find((r) => !r.noticed);
2548
+ if (release) release.noticed = true;
2549
+ else this.serverEnds.get(notice.collection)?.next(notice);
2550
+ });
2551
+ }
2413
2552
  /**
2414
2553
  * Convenience wrapper for auth.generate_token API call.
2415
2554
  *
@@ -2442,12 +2581,10 @@ var TrueNasApi = class {
2442
2581
  rxjs.filter((job) => job.id === jobId),
2443
2582
  rxjs.map((job) => job)
2444
2583
  );
2445
- const read$ = this.dispatch("core.get_jobs", [
2446
- [["id", "=", jobId]]
2447
- ]).pipe(
2448
- rxjs.map((jobs) => jobs[0]),
2449
- rxjs.share()
2584
+ const readJob = () => this.dispatch("core.get_jobs", [[["id", "=", jobId]]]).pipe(
2585
+ rxjs.map((jobs) => jobs[0])
2450
2586
  );
2587
+ const read$ = readJob().pipe(rxjs.share());
2451
2588
  const missing$ = read$.pipe(
2452
2589
  rxjs.filter((job) => job === void 0),
2453
2590
  rxjs.takeUntil(updates$)
@@ -2456,10 +2593,29 @@ var TrueNasApi = class {
2456
2593
  rxjs.filter((job) => job !== void 0),
2457
2594
  rxjs.takeUntil(updates$)
2458
2595
  );
2459
- return rxjs.merge(snapshot$, updates$).pipe(
2596
+ const reread$ = this.authenticated.pipe(
2597
+ rxjs.distinctUntilChanged(),
2598
+ rxjs.skipWhile((isUp) => !isUp),
2599
+ rxjs.pairwise(),
2600
+ rxjs.filter(([wasUp, isUp]) => !wasUp && isUp),
2601
+ rxjs.switchMap(
2602
+ () => readJob().pipe(
2603
+ // Stale once events flow again, as the opening read is; they carry the end.
2604
+ rxjs.takeUntil(updates$),
2605
+ // A failed read waits for the next reconnect.
2606
+ rxjs.catchError(() => rxjs.EMPTY)
2607
+ )
2608
+ ),
2609
+ rxjs.share()
2610
+ );
2611
+ const gone$ = reread$.pipe(rxjs.filter((job) => job === void 0));
2612
+ const rereadJob$ = reread$.pipe(
2613
+ rxjs.filter((job) => job !== void 0)
2614
+ );
2615
+ return rxjs.merge(snapshot$, updates$, rereadJob$).pipe(
2460
2616
  rxjs.takeWhile((job) => !isJobFinished(job), true),
2461
2617
  // Include the final state
2462
- rxjs.takeUntil(missing$)
2618
+ rxjs.takeUntil(rxjs.merge(missing$, gone$))
2463
2619
  );
2464
2620
  }
2465
2621
  /**
@@ -4370,6 +4526,7 @@ exports.TrueNasApiClient = TrueNasApiClient;
4370
4526
  exports.TrueNasApiClientV2510 = TrueNasApiClientV2510;
4371
4527
  exports.TrueNasApiClientV27 = TrueNasApiClientV27;
4372
4528
  exports.TrueNasApiClientV28 = TrueNasApiClientV28;
4529
+ exports.TrueNasApiError = TrueNasApiError;
4373
4530
  exports.TrueNasAuthMechanism = TrueNasAuthMechanism;
4374
4531
  exports.TrueNasAuthenticator = TrueNasAuthenticator;
4375
4532
  exports.TrueNasConnection = TrueNasConnection;
@@ -4391,6 +4548,7 @@ exports.isJobFinished = isJobFinished;
4391
4548
  exports.noopLogger = noopLogger;
4392
4549
  exports.parseApiVersion = parseApiVersion;
4393
4550
  exports.policyViolationCloseCode = policyViolationCloseCode;
4551
+ exports.subscriptionName = subscriptionName;
4394
4552
  exports.v25_10_0_exports = v25_10_0_exports;
4395
4553
  exports.v25_10_1_exports = v25_10_1_exports;
4396
4554
  exports.v25_10_2_exports = v25_10_2_exports;
@@ -4399,5 +4557,5 @@ exports.v25_10_4_exports = v25_10_4_exports;
4399
4557
  exports.v25_10_5_exports = v25_10_5_exports;
4400
4558
  exports.v27_0_0_exports = v27_0_0_exports;
4401
4559
  exports.v28_0_0_exports = v28_0_0_exports;
4402
- //# sourceMappingURL=chunk-TSO2EHF3.cjs.map
4403
- //# sourceMappingURL=chunk-TSO2EHF3.cjs.map
4560
+ //# sourceMappingURL=chunk-U6V2Y3AE.cjs.map
4561
+ //# sourceMappingURL=chunk-U6V2Y3AE.cjs.map