@effected/github 0.18.0 → 0.20.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 (3) hide show
  1. package/GitHubApp.js +86 -41
  2. package/index.d.ts +38 -9
  3. package/package.json +5 -5
package/GitHubApp.js CHANGED
@@ -4,7 +4,7 @@ import { GitHubClient, makeClientShape } from "./GitHubClient.js";
4
4
  import { numericId } from "./internal/ids.js";
5
5
  import { InstallationTokenStore } from "./InstallationTokenStore.js";
6
6
  import { Jwt, JwtKey } from "@effected/jwt";
7
- import { Cause, Clock, Context, DateTime, Duration, Effect, Layer, Option, Redacted, Ref, Schema, Semaphore, Stream } from "effect";
7
+ import { Cause, Clock, Context, DateTime, Deferred, Duration, Effect, Layer, Option, Redacted, Ref, Schema, Semaphore, Stream } from "effect";
8
8
 
9
9
  //#region src/GitHubApp.ts
10
10
  /**
@@ -373,14 +373,23 @@ var GitHubApp = class GitHubApp extends Context.Service()("@effected/github/GitH
373
373
  * **A cached token is never revoked**: another scope or isolate may be using
374
374
  * it. It expires on its own, within the hour.
375
375
  *
376
- * Two concurrent calls that both miss will both mint, and the later write
377
- * wins. That is expected: the store may span isolates, so the kit cannot
378
- * lock it, and an extra token costs one call and expires on its own.
376
+ * Within one process, concurrent misses for one installation are
377
+ * **single-flight**: the first caller mints and the others wait for that
378
+ * mint and return the same token with `source: "joined"`, so a batch that
379
+ * fans out over one installation on a cold store costs one mint, not one
380
+ * per fiber. The in-flight record lives with the `GitHubApp` service and is
381
+ * released when the mint settles, however it settles: a failed mint fails
382
+ * every waiter with the same `GitHubAppError`, and the next call mints
383
+ * again. Interrupting the leader does not strand the waiters: the mint runs
384
+ * in its own fiber and completes for them. Across isolates, two misses
385
+ * still both mint and the later write wins. That is expected: the store may
386
+ * span isolates, so the kit cannot lock it, and an extra token costs one
387
+ * call and expires on its own.
379
388
  *
380
389
  * The encoded value written to the store contains the raw token, so
381
390
  * encrypting it at rest is the store's job.
382
391
  */
383
- static cachedToken = (request) => cachedTokenFor(request);
392
+ static cachedToken = (request) => Effect.flatMap(GitHubApp, (app) => app.cachedToken(request));
384
393
  /**
385
394
  * A {@link GitHubClient} authenticated with {@link GitHubApp.cachedToken}.
386
395
  *
@@ -422,19 +431,23 @@ var GitHubApp = class GitHubApp extends Context.Service()("@effected/github/GitH
422
431
  * Effect.runPromise(Effect.provide(handle(42), Live));
423
432
  * ```
424
433
  */
425
- static cachedClientLayer = (request, options = {}) => Layer.unwrap(Effect.map(cachedTokenFor(request), (cached) => GitHubClient.layerFromToken({
434
+ static cachedClientLayer = (request, options = {}) => Layer.unwrap(Effect.map(GitHubApp.cachedToken(request), (cached) => GitHubClient.layerFromToken({
426
435
  ...options,
427
436
  token: cached.token.token
428
437
  })));
429
438
  /** An in-memory double; unstubbed members die naming themselves. */
430
- static makeTest = (overrides = {}) => ({
431
- token: overrides.token ?? (() => unstubbed("token")),
432
- scopedToken: overrides.scopedToken ?? (() => unstubbed("scopedToken")),
433
- revoke: overrides.revoke ?? (() => unstubbed("revoke")),
434
- identity: overrides.identity ?? (() => unstubbed("identity")),
435
- botUser: overrides.botUser ?? (() => unstubbed("botUser")),
436
- installations: overrides.installations ?? (() => unstubbed("installations"))
437
- });
439
+ static makeTest = (overrides = {}) => {
440
+ const token = overrides.token ?? (() => unstubbed("token"));
441
+ return {
442
+ token,
443
+ scopedToken: overrides.scopedToken ?? (() => unstubbed("scopedToken")),
444
+ revoke: overrides.revoke ?? (() => unstubbed("revoke")),
445
+ identity: overrides.identity ?? (() => unstubbed("identity")),
446
+ botUser: overrides.botUser ?? (() => unstubbed("botUser")),
447
+ installations: overrides.installations ?? (() => unstubbed("installations")),
448
+ cachedToken: overrides.cachedToken ?? makeCachedToken(token)
449
+ };
450
+ };
438
451
  /** {@link GitHubApp.makeTest} behind a `Layer`. */
439
452
  static layerTest = (overrides = {}) => Layer.succeed(GitHubApp, GitHubApp.makeTest(overrides));
440
453
  };
@@ -444,33 +457,64 @@ const DEFAULT_CACHE_MARGIN = Duration.minutes(5);
444
457
  const StoredToken = Schema.fromJsonString(InstallationToken);
445
458
  /** A store call that can never fail its caller: failures and defects become `fallback`; interruption propagates. */
446
459
  const swallowStore = (effect, fallback) => Effect.catchCause(effect, (cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.succeed(fallback));
447
- const cachedTokenFor = Effect.fn("GitHubApp.cachedToken")(function* (request) {
448
- const input = request.margin ?? DEFAULT_CACHE_MARGIN;
449
- const margin = typeof input === "number" && !Number.isFinite(input) ? Option.none() : Option.filter(Duration.fromInput(input), (duration) => Duration.isFinite(duration) && !Duration.isNegative(duration));
450
- if (Option.isNone(margin)) return yield* GitHubAppError.of("token", "the cache margin must be a finite, non-negative duration");
451
- const app = yield* GitHubApp;
452
- const store = yield* InstallationTokenStore;
453
- const stored = yield* swallowStore(store.get(request.installationId), Option.none());
454
- if (Option.isSome(stored)) {
455
- const decoded = yield* Effect.option(Schema.decodeUnknownEffect(StoredToken)(stored.value));
456
- const now = yield* Clock.currentTimeMillis;
457
- if (Option.isSome(decoded) && decoded.value.installationId === request.installationId && !decoded.value.isExpired(now, margin.value)) return {
458
- token: decoded.value,
459
- source: "cached"
460
+ /**
461
+ * The caching read over `token`, with one in-flight mint per installation.
462
+ *
463
+ * @remarks
464
+ * The map is the single-flight record. A key is present exactly while a mint
465
+ * for that installation is running in this process; the first miss creates it
466
+ * and forks the mint, later misses await the same `Deferred`, and the mint's
467
+ * exit finalizer deletes the key before completing the `Deferred` with
468
+ * whatever exit the mint produced, so a failed mint fails every waiter with the
469
+ * same error and never pins the key. The mint is forked detached from the
470
+ * leader, so the leader being interrupted does not interrupt the mint the
471
+ * waiters depend on; the leader itself just stops waiting. The key is the
472
+ * installation id alone because that is also the store's key: a `TokenRequest`
473
+ * cannot narrow a token, so two requests for one installation ask for the same
474
+ * token.
475
+ */
476
+ const makeCachedToken = (token) => {
477
+ const inFlight = /* @__PURE__ */ new Map();
478
+ return Effect.fn("GitHubApp.cachedToken")(function* (request) {
479
+ const input = request.margin ?? DEFAULT_CACHE_MARGIN;
480
+ const margin = typeof input === "number" && !Number.isFinite(input) ? Option.none() : Option.filter(Duration.fromInput(input), (duration) => Duration.isFinite(duration) && !Duration.isNegative(duration));
481
+ if (Option.isNone(margin)) return yield* GitHubAppError.of("token", "the cache margin must be a finite, non-negative duration");
482
+ const store = yield* InstallationTokenStore;
483
+ const stored = yield* swallowStore(store.get(request.installationId), Option.none());
484
+ if (Option.isSome(stored)) {
485
+ const decoded = yield* Effect.option(Schema.decodeUnknownEffect(StoredToken)(stored.value));
486
+ const now = yield* Clock.currentTimeMillis;
487
+ if (Option.isSome(decoded) && decoded.value.installationId === request.installationId && !decoded.value.isExpired(now, margin.value)) return {
488
+ token: decoded.value,
489
+ source: "cached"
490
+ };
491
+ }
492
+ const joined = inFlight.get(request.installationId);
493
+ if (joined !== void 0) return {
494
+ token: yield* Deferred.await(joined),
495
+ source: "joined"
460
496
  };
461
- }
462
- const minted = yield* app.token(request);
463
- const now = yield* Clock.currentTimeMillis;
464
- const ttlMillis = DateTime.toEpochMillis(minted.expiresAt) - Duration.toMillis(margin.value) - now;
465
- if (ttlMillis > 0) {
466
- const encoded = yield* Effect.option(Schema.encodeUnknownEffect(StoredToken)(minted));
467
- if (Option.isSome(encoded)) yield* swallowStore(store.set(request.installationId, encoded.value, Duration.millis(ttlMillis)), void 0);
468
- }
469
- return {
470
- token: minted,
471
- source: "minted"
472
- };
473
- });
497
+ const deferred = Deferred.makeUnsafe();
498
+ const mint = Effect.gen(function* () {
499
+ const minted = yield* token(request);
500
+ const now = yield* Clock.currentTimeMillis;
501
+ const ttlMillis = DateTime.toEpochMillis(minted.expiresAt) - Duration.toMillis(margin.value) - now;
502
+ if (ttlMillis > 0) {
503
+ const encoded = yield* Effect.option(Schema.encodeUnknownEffect(StoredToken)(minted));
504
+ if (Option.isSome(encoded)) yield* swallowStore(store.set(request.installationId, encoded.value, Duration.millis(ttlMillis)), void 0);
505
+ }
506
+ return minted;
507
+ }).pipe(Effect.onExit((exit) => Effect.andThen(Effect.sync(() => inFlight.delete(request.installationId)), Deferred.done(deferred, exit))));
508
+ yield* Effect.uninterruptible(Effect.suspend(() => {
509
+ inFlight.set(request.installationId, deferred);
510
+ return Effect.forkDetach(mint);
511
+ }));
512
+ return {
513
+ token: yield* Deferred.await(deferred),
514
+ source: "minted"
515
+ };
516
+ });
517
+ };
474
518
  const unstubbed = (member) => {
475
519
  throw new Error(`GitHubApp.makeTest: ${member}() was called but not stubbed — pass an override.`);
476
520
  };
@@ -566,7 +610,8 @@ function makeApp(options) {
566
610
  login: user.login
567
611
  } : raw).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubAppError.of("identity", "GitHub returned an unexpected bot user", error))));
568
612
  }),
569
- installations
613
+ installations,
614
+ cachedToken: makeCachedToken(token)
570
615
  };
571
616
  });
572
617
  }
package/index.d.ts CHANGED
@@ -4,7 +4,6 @@ import { Cause, Config, Context, Duration, Effect, Layer, Option, Redacted, Resu
4
4
  import { PaginatingEndpoints } from "@octokit/plugin-paginate-rest";
5
5
  import { Endpoints, RequestHeaders } from "@octokit/types";
6
6
  import { SemVer } from "@effected/semver";
7
- //#endregion
8
7
  //#region src/ActionsOidc.d.ts
9
8
  declare const ActionsOidcClaims_base: Schema.Class<ActionsOidcClaims, Schema.Struct<{
10
9
  /** The issuer: always {@link ActionsOidc.issuer} once verified. */
@@ -600,9 +599,8 @@ export declare class RetryPolicy extends RetryPolicy_base {
600
599
  */
601
600
  schedule<E extends RetryableFailure>(): Schedule.Schedule<number, E>;
602
601
  }
603
- declare namespace Rest_d_exports {
604
- export { Data, Item, PageOptions, PaginatingRoute, Params, RequestExtras, Response, Route };
605
- }
602
+ //#endregion
603
+ //#region src/Rest.d.ts
606
604
  /**
607
605
  * Every REST route GitHub documents, as a `"<METHOD> <path>"` literal — for
608
606
  * example `"GET /repos/{owner}/{repo}"`.
@@ -2474,8 +2472,17 @@ interface CachedTokenRequest extends TokenRequest {
2474
2472
  interface CachedToken {
2475
2473
  /** The token. */
2476
2474
  readonly token: InstallationToken;
2477
- /** `"cached"` when it was read from the store, `"minted"` when GitHub issued it just now. */
2478
- readonly source: "cached" | "minted";
2475
+ /**
2476
+ * `"cached"` when it was read from the store, `"minted"` when GitHub
2477
+ * issued it to this call just now, `"joined"` when this call missed the
2478
+ * store but another call in the same process was already minting for the
2479
+ * installation and this one took that token instead of minting its own.
2480
+ *
2481
+ * @remarks
2482
+ * A `"joined"` token is as fresh as a `"minted"` one; the label exists so
2483
+ * a caller counting mints against GitHub can tell the two apart.
2484
+ */
2485
+ readonly source: "cached" | "minted" | "joined";
2479
2486
  }
2480
2487
  declare const InstallationToken_base: Schema.Class<InstallationToken, Schema.Struct<{
2481
2488
  /** The token. Decodes to `Redacted`, encodes back to the raw string. */
@@ -2803,9 +2810,18 @@ export declare class GitHubApp extends GitHubApp_base {
2803
2810
  * **A cached token is never revoked**: another scope or isolate may be using
2804
2811
  * it. It expires on its own, within the hour.
2805
2812
  *
2806
- * Two concurrent calls that both miss will both mint, and the later write
2807
- * wins. That is expected: the store may span isolates, so the kit cannot
2808
- * lock it, and an extra token costs one call and expires on its own.
2813
+ * Within one process, concurrent misses for one installation are
2814
+ * **single-flight**: the first caller mints and the others wait for that
2815
+ * mint and return the same token with `source: "joined"`, so a batch that
2816
+ * fans out over one installation on a cold store costs one mint, not one
2817
+ * per fiber. The in-flight record lives with the `GitHubApp` service and is
2818
+ * released when the mint settles, however it settles: a failed mint fails
2819
+ * every waiter with the same `GitHubAppError`, and the next call mints
2820
+ * again. Interrupting the leader does not strand the waiters: the mint runs
2821
+ * in its own fiber and completes for them. Across isolates, two misses
2822
+ * still both mint and the later write wins. That is expected: the store may
2823
+ * span isolates, so the kit cannot lock it, and an extra token costs one
2824
+ * call and expires on its own.
2809
2825
  *
2810
2826
  * The encoded value written to the store contains the raw token, so
2811
2827
  * encrypting it at rest is the store's job.
@@ -2912,6 +2928,19 @@ interface GitHubAppShape {
2912
2928
  readonly botUser: (request: BotUserRequest) => Effect.Effect<BotUser, GitHubAppError>;
2913
2929
  /** Every installation of the app. */
2914
2930
  readonly installations: (credentials: AppCredentials) => Effect.Effect<ReadonlyArray<Installation>, GitHubAppError>;
2931
+ /**
2932
+ * An installation token, reused from the {@link InstallationTokenStore}
2933
+ * while it has `margin` left to live; {@link GitHubApp.cachedToken} is this
2934
+ * member on the provided service.
2935
+ *
2936
+ * @remarks
2937
+ * A member rather than a free function because the in-process
2938
+ * single-flight record for concurrent misses lives with the service: it is
2939
+ * created with the layer and shared by every call through it.
2940
+ * {@link GitHubApp.makeTest} builds it over the double's `token`, so a test
2941
+ * that stubs `token` gets the real caching and single-flight behaviour.
2942
+ */
2943
+ readonly cachedToken: (request: CachedTokenRequest) => Effect.Effect<CachedToken, GitHubAppError, InstallationTokenStore>;
2915
2944
  }
2916
2945
  //#endregion
2917
2946
  //#region src/GitHubCommit.d.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/github",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "private": false,
5
5
  "description": "Typed GitHub REST and GraphQL services over the octokit core request surface, with app auth and resource helpers.",
6
6
  "keywords": [
@@ -38,9 +38,9 @@
38
38
  "./package.json": "./package.json"
39
39
  },
40
40
  "dependencies": {
41
- "@effected/github-references": "^0.7.1",
42
- "@effected/jwt": "^0.1.1",
43
- "@effected/semver": "^0.11.1",
41
+ "@effected/github-references": "^0.9.0",
42
+ "@effected/jwt": "^0.3.0",
43
+ "@effected/semver": "^0.13.0",
44
44
  "@octokit/core": "^7.0.6",
45
45
  "@octokit/plugin-paginate-rest": "^16.0.0",
46
46
  "@octokit/types": "^18.0.0",
@@ -48,7 +48,7 @@
48
48
  "tweetnacl": "1.0.3"
49
49
  },
50
50
  "peerDependencies": {
51
- "effect": "4.0.2"
51
+ "effect": "^4.0.0"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=24.11.0"