@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.
- package/GitHubApp.js +86 -41
- package/index.d.ts +38 -9
- 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
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
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) =>
|
|
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(
|
|
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
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
604
|
-
|
|
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
|
-
/**
|
|
2478
|
-
|
|
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
|
-
*
|
|
2807
|
-
*
|
|
2808
|
-
*
|
|
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.
|
|
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.
|
|
42
|
-
"@effected/jwt": "^0.
|
|
43
|
-
"@effected/semver": "^0.
|
|
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.
|
|
51
|
+
"effect": "^4.0.0"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=24.11.0"
|