@homeflare/alchemy 0.27.1 → 0.27.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.
Files changed (71) hide show
  1. package/README.md +3 -2
  2. package/dist/cloudflare/fake-r2-lock.d.ts +53 -0
  3. package/dist/cloudflare/fake-r2-lock.d.ts.map +1 -0
  4. package/dist/cloudflare/index.d.ts +4 -5
  5. package/dist/cloudflare/index.d.ts.map +1 -1
  6. package/dist/cloudflare/index.js +79 -103
  7. package/dist/cloudflare/index.js.map +6 -7
  8. package/dist/cloudflare/mesh-node-api.d.ts +3 -2
  9. package/dist/cloudflare/mesh-node-api.d.ts.map +1 -1
  10. package/dist/cloudflare/providers.d.ts +1 -1
  11. package/dist/cloudflare/providers.d.ts.map +1 -1
  12. package/dist/cloudflare/r2-bucket-lock.d.ts +28 -21
  13. package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
  14. package/dist/forgejo/branch-protection-form.d.ts +16 -5
  15. package/dist/forgejo/branch-protection-form.d.ts.map +1 -1
  16. package/dist/forgejo/branch-protection.d.ts +34 -3
  17. package/dist/forgejo/branch-protection.d.ts.map +1 -1
  18. package/dist/forgejo/fake-forgejo.d.ts +41 -0
  19. package/dist/forgejo/fake-forgejo.d.ts.map +1 -0
  20. package/dist/forgejo/index.js +220 -284
  21. package/dist/forgejo/index.js.map +14 -15
  22. package/dist/forgejo/org-actions-secrets.d.ts +42 -12
  23. package/dist/forgejo/org-actions-secrets.d.ts.map +1 -1
  24. package/dist/forgejo/org-label.d.ts +30 -3
  25. package/dist/forgejo/org-label.d.ts.map +1 -1
  26. package/dist/forgejo/org-team.d.ts +37 -4
  27. package/dist/forgejo/org-team.d.ts.map +1 -1
  28. package/dist/forgejo/repo-webhook-form.d.ts +9 -18
  29. package/dist/forgejo/repo-webhook-form.d.ts.map +1 -1
  30. package/dist/forgejo/repo-webhook.d.ts +35 -3
  31. package/dist/forgejo/repo-webhook.d.ts.map +1 -1
  32. package/dist/forgejo/repository.d.ts +47 -2
  33. package/dist/forgejo/repository.d.ts.map +1 -1
  34. package/dist/forgejo/resource.d.ts +37 -37
  35. package/dist/forgejo/resource.d.ts.map +1 -1
  36. package/dist/forgejo/team-member.d.ts +48 -4
  37. package/dist/forgejo/team-member.d.ts.map +1 -1
  38. package/dist/forgejo/values.d.ts +9 -5
  39. package/dist/forgejo/values.d.ts.map +1 -1
  40. package/docs/credentials.md +6 -0
  41. package/docs/distilled-interim.md +151 -0
  42. package/docs/peers.md +15 -6
  43. package/docs/upstream-conformance.md +46 -22
  44. package/package.json +2 -2
  45. package/src/cloudflare/fake-r2-lock.ts +120 -0
  46. package/src/cloudflare/index.ts +4 -5
  47. package/src/cloudflare/mesh-node-api.ts +3 -2
  48. package/src/cloudflare/providers.ts +13 -16
  49. package/src/cloudflare/r2-bucket-lock.test.ts +139 -0
  50. package/src/cloudflare/r2-bucket-lock.ts +59 -51
  51. package/src/forgejo/branch-protection-form.ts +12 -5
  52. package/src/forgejo/branch-protection.ts +58 -26
  53. package/src/forgejo/fake-forgejo.ts +54 -0
  54. package/src/forgejo/org-actions-secrets.ts +44 -36
  55. package/src/forgejo/org-label.test.ts +100 -0
  56. package/src/forgejo/org-label.ts +46 -36
  57. package/src/forgejo/org-team.ts +40 -27
  58. package/src/forgejo/repo-webhook-form.ts +10 -27
  59. package/src/forgejo/repo-webhook.ts +66 -35
  60. package/src/forgejo/repository.test.ts +125 -0
  61. package/src/forgejo/repository.ts +72 -52
  62. package/src/forgejo/resource.ts +80 -79
  63. package/src/forgejo/team-member.test.ts +97 -0
  64. package/src/forgejo/team-member.ts +56 -38
  65. package/src/forgejo/values.ts +9 -17
  66. package/dist/cloudflare/client.d.ts +0 -45
  67. package/dist/cloudflare/client.d.ts.map +0 -1
  68. package/dist/forgejo/client.d.ts +0 -37
  69. package/dist/forgejo/client.d.ts.map +0 -1
  70. package/src/cloudflare/client.ts +0 -76
  71. package/src/forgejo/client.ts +0 -136
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `Forgejo.Repository`'s `fetchLive` against a fake Forgejo, proving the tagged-error paths that
3
+ * replaced `client.ts`'s status-carrying `ForgejoError`:
4
+ *
5
+ * - a real distilled `NotFound` (HTTP 404) folds to `undefined` — resource.ts's engine sees the
6
+ * repository as absent, the same outcome the old `absentOn404` produced for a 404.
7
+ * - a real distilled `Forbidden` (HTTP 403) is NOT folded — it fails the read, because folding it
8
+ * would plan a create over a repository this token simply cannot see.
9
+ *
10
+ * Both run the REAL `@distilled.cloud/forgejo` protocol (path assembly, JSON decode, status→typed
11
+ * error matching) against an in-memory `fetch`; nothing about the matching is asserted by hand.
12
+ */
13
+ import { describe, expect, test } from 'bun:test';
14
+ import * as repository from '@distilled.cloud/forgejo/repository';
15
+ import * as Effect from 'effect/Effect';
16
+ import { FAKE_BASE, fakeFailure, fakeForgejo, fakeForgejoLayer } from './fake-forgejo.ts';
17
+ import { spec } from './repository.ts';
18
+
19
+ const REPO_PATH = '/api/v1/repos/homeflare/kit';
20
+
21
+ const liveRepo = () =>
22
+ Response.json({
23
+ clone_url: `${FAKE_BASE}/homeflare/kit.git`,
24
+ created_at: '2026-01-01T00:00:00Z',
25
+ default_branch: 'main',
26
+ description: 'the kit',
27
+ full_name: 'homeflare/kit',
28
+ has_issues: true,
29
+ has_projects: true,
30
+ has_wiki: false,
31
+ html_url: `${FAKE_BASE}/homeflare/kit`,
32
+ id: 42,
33
+ name: 'kit',
34
+ owner: { id: 1, login: 'homeflare' },
35
+ private: true,
36
+ ssh_url: 'git@forgejo.example.com:homeflare/kit.git',
37
+ updated_at: '2026-01-01T00:00:00Z',
38
+ });
39
+
40
+ describe('Forgejo.Repository fetchLive', () => {
41
+ test('a live repository decodes into typed attributes', async () => {
42
+ const fake = fakeForgejo((method, url) =>
43
+ method === 'GET' && url.pathname === REPO_PATH ? liveRepo() : fakeFailure(404, 'not found'),
44
+ );
45
+ const live = await Effect.runPromise(
46
+ repository
47
+ .getRepo({ owner: 'homeflare', repo: 'kit' })
48
+ .pipe(Effect.provide(fakeForgejoLayer(fake.fetch))),
49
+ );
50
+ expect(live.id).toBe(42);
51
+ expect(live.has_wiki).toBe(false);
52
+ expect(fake.seen).toEqual([{ method: 'GET', path: REPO_PATH }]);
53
+ });
54
+
55
+ test('a 404 folds to undefined — the exact `fetchLive` idiom repository.ts uses', async () => {
56
+ // ⚠️ This is the same one-line `.pipe(Effect.catchTag('NotFound', ...))` repository.ts's
57
+ // `fetchLive` ends with, exercised against a real distilled decode of a real 404 response —
58
+ // the direct successor to client.ts's `absentOn404` folding a raw HTTP 404.
59
+ const fake = fakeForgejo(() => fakeFailure(404, 'repository does not exist'));
60
+ const result = await Effect.runPromise(
61
+ repository.getRepo({ owner: 'homeflare', repo: 'ghost' }).pipe(
62
+ Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
63
+ Effect.provide(fakeForgejoLayer(fake.fetch)),
64
+ ),
65
+ );
66
+ expect(result).toBeUndefined();
67
+ });
68
+
69
+ test('a 401 propagates — Unauthorized is a real failure, never folded to absent', async () => {
70
+ // ⚠️ 401 rather than 403: `getRepo`'s own declared errors are only `NotFound` (plus the
71
+ // channel every operation shares) — Unauthorized is in that shared default set, decoded
72
+ // for every operation regardless of what it declares, so this does not depend on
73
+ // `getRepo` happening to also list Forbidden.
74
+ const fake = fakeForgejo(() => fakeFailure(401, 'bad token'));
75
+ const failure = await Effect.runPromise(
76
+ Effect.flip(
77
+ repository.getRepo({ owner: 'homeflare', repo: 'kit' }).pipe(
78
+ Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
79
+ Effect.provide(fakeForgejoLayer(fake.fetch)),
80
+ ),
81
+ ),
82
+ );
83
+ expect(failure._tag).toBe('Unauthorized');
84
+ });
85
+ });
86
+
87
+ describe('Forgejo.Repository spec — the actual exported production functions', () => {
88
+ // ⛔ THE POINT OF THIS BLOCK. The describe above proves the SDK's own `getRepo` +
89
+ // `catchTag('NotFound', ...)` idiom works; it does not touch a single line of
90
+ // repository.ts. This block calls `spec.fetchLive` and `spec.attributes` — the exact object
91
+ // `forgejoHandlers(spec)` wraps into `ForgejoRepositoryProvider` — so a typo that dropped or
92
+ // mistagged the `catchTag` in `fetchLive`, or a drifted `attributes()` mapping, fails HERE.
93
+ const props = { name: 'kit', org: 'homeflare' };
94
+
95
+ const readThrough = (fetchFn: typeof globalThis.fetch) =>
96
+ Effect.runPromise(
97
+ spec.fetchLive(props).pipe(
98
+ Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
99
+ Effect.provide(fakeForgejoLayer(fetchFn)),
100
+ ),
101
+ );
102
+
103
+ test('spec.fetchLive + spec.attributes match the old field mapping', async () => {
104
+ const fake = fakeForgejo((method, url) =>
105
+ method === 'GET' && url.pathname === REPO_PATH ? liveRepo() : fakeFailure(404, 'not found'),
106
+ );
107
+ expect(await readThrough(fake.fetch)).toEqual({
108
+ defaultBranch: 'main',
109
+ description: 'the kit',
110
+ empty: false,
111
+ hasIssues: true,
112
+ hasProjects: true,
113
+ hasWiki: false,
114
+ name: 'kit',
115
+ org: 'homeflare',
116
+ private: true,
117
+ repoId: 42,
118
+ });
119
+ });
120
+
121
+ test('a 404 through spec.fetchLive is undefined, not a thrown failure', async () => {
122
+ const fake = fakeForgejo(() => fakeFailure(404, 'repository does not exist'));
123
+ expect(await readThrough(fake.fetch)).toBeUndefined();
124
+ });
125
+ });
@@ -3,16 +3,27 @@
3
3
  *
4
4
  * ★ MEASURED 2026-09-13 against the live daemon (`GET /api/v1/repos/<org>/<repo>`):
5
5
  * responses are flat JSON with `private`, `has_issues`, `default_branch`, etc. Create is
6
- * `POST /orgs/{org}/repos`; read/update/delete use `/repos/{owner}/{repo}`.
6
+ * `POST /orgs/{org}/repos`; read/update/delete use `/repos/{owner}/{repo}`. Now called through
7
+ * `@distilled.cloud/forgejo`'s `organization.createOrgRepo` / `repository.{getRepo,editRepo,
8
+ * deleteRepo}` instead of the retired hand-rolled client (resource.ts).
9
+ *
10
+ * ⛔ `CreateOrgRepoRequest` HAS NO `has_issues` / `has_wiki` / `has_projects`. Verified against
11
+ * the package's typed schema: Forgejo's own `POST /orgs/{org}/repos` never accepted them either
12
+ * (the hand-rolled client sent them anyway; Gitea's JSON decoder silently drops unknown keys,
13
+ * so they were always ignored on create). `editRepo`'s schema does carry them, and
14
+ * `updateForm`/`matches` below still converge them on the first non-create reconcile — the same
15
+ * two-step behaviour the old client had, now provable at the type level instead of by reading
16
+ * Gitea's source.
7
17
  *
8
18
  * ⛔ `defaultRemovalPolicy: 'retain'` — history, issues and packages in a repo are irreplaceable.
9
19
  * Opt into deletion with `.pipe(RemovalPolicy.destroy())`; `delete` below is fully implemented.
10
20
  */
11
21
  import { Resource } from 'alchemy';
12
22
  import * as Provider from 'alchemy/Provider';
23
+ import * as organization from '@distilled.cloud/forgejo/organization';
24
+ import * as repository from '@distilled.cloud/forgejo/repository';
13
25
  import * as Effect from 'effect/Effect';
14
- import { type ForgejoRequirements, forgejoHandlers } from './resource.ts';
15
- import { bool, text } from './values.ts';
26
+ import { type ForgejoRequirements, type ForgejoSpec, forgejoHandlers } from './resource.ts';
16
27
 
17
28
  export interface RepositoryProps {
18
29
  /** Organization login — the owner segment in `<org>/<repo>`. */
@@ -55,51 +66,65 @@ export const ForgejoRepository = Resource<ForgejoRepository>('Forgejo.Repository
55
66
  defaultRemovalPolicy: 'retain',
56
67
  });
57
68
 
58
- const handlers = forgejoHandlers<RepositoryProps, RepositoryAttributes>({
59
- attributes: (live, props) => {
60
- const id = live['id'];
61
- if (typeof id !== 'number') return undefined;
62
- return {
63
- defaultBranch: text(live['default_branch'], 'main'),
64
- description: text(live['description']),
65
- empty: bool(live['empty']),
66
- hasIssues: bool(live['has_issues'], true),
67
- hasProjects: bool(live['has_projects'], true),
68
- hasWiki: bool(live['has_wiki'], true),
69
- name: props.name,
70
- org: props.org,
71
- private: bool(live['private'], true),
72
- repoId: id,
73
- };
74
- },
75
- collection: (props) => `orgs/${props.org}/repos`,
76
- createForm: (props) => ({
77
- auto_init: props.autoInit ?? false,
78
- description: props.description ?? '',
79
- has_issues: props.hasIssues ?? true,
80
- has_projects: props.hasProjects ?? true,
81
- has_wiki: props.hasWiki ?? true,
69
+ const editForm = (props: RepositoryProps) => ({
70
+ ...(props.defaultBranch === undefined ? {} : { default_branch: props.defaultBranch }),
71
+ ...(props.description === undefined ? {} : { description: props.description }),
72
+ ...(props.hasIssues === undefined ? {} : { has_issues: props.hasIssues }),
73
+ ...(props.hasProjects === undefined ? {} : { has_projects: props.hasProjects }),
74
+ ...(props.hasWiki === undefined ? {} : { has_wiki: props.hasWiki }),
75
+ ...(props.private === undefined ? {} : { private: props.private }),
76
+ });
77
+
78
+ /**
79
+ * ★ EXPORTED, NOT JUST PASSED INLINE TO `forgejoHandlers` BELOW — so a test can call
80
+ * `spec.fetchLive` / `spec.attributes` directly (repository.test.ts does), against an explicit
81
+ * fake `Credentials` layer, without touching `forgejoHandlers`'s baked-in `CredentialsFromEnv`.
82
+ * That layer resolves `FORGEJO_URL` / `FORGEJO_TOKEN` through Effect's `Config`, whose default
83
+ * provider snapshots `process.env` once — a test-time `process.env` mutation is invisible to it,
84
+ * so the only way to run this exact production code against a fake server is to call it with an
85
+ * explicit `Credentials` layer, bypassing the env read entirely.
86
+ */
87
+ export const spec: ForgejoSpec<
88
+ RepositoryProps,
89
+ repository.Repository,
90
+ RepositoryAttributes,
91
+ | organization.CreateOrgRepoError
92
+ | repository.GetRepoError
93
+ | repository.EditRepoError
94
+ | repository.DeleteRepoError
95
+ > = {
96
+ attributes: (live, props) => ({
97
+ defaultBranch: live.default_branch,
98
+ description: live.description ?? '',
99
+ empty: live.empty ?? false,
100
+ hasIssues: live.has_issues ?? true,
101
+ hasProjects: live.has_projects ?? true,
102
+ hasWiki: live.has_wiki ?? true,
82
103
  name: props.name,
83
- private: props.private ?? true,
104
+ org: props.org,
105
+ private: live.private ?? true,
106
+ repoId: live.id,
84
107
  }),
108
+ create: (props) =>
109
+ organization.createOrgRepo({
110
+ auto_init: props.autoInit ?? false,
111
+ description: props.description ?? '',
112
+ name: props.name,
113
+ org: props.org,
114
+ private: props.private ?? true,
115
+ }),
116
+ destroy: (props) => repository.deleteRepo({ owner: props.org, repo: props.name }),
117
+ fetchLive: (props) =>
118
+ repository
119
+ .getRepo({ owner: props.org, repo: props.name })
120
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
85
121
  /**
86
122
  * ⛔ AN UNDECLARED FIELD IS NEITHER COMPARED NOR SENT — the rule every other family in this
87
123
  * estate follows (storage.ts, backup-job.ts, and all four Pbs.* families say it in those
88
- * words), and this file was the exception.
89
- *
90
- * 🔴 WHAT THE EXCEPTION DID. Five fields were compared against a DEFAULT rather than skipped:
91
- * `description ?? ''`, and `?? true` for private, hasIssues, hasWiki and hasProjects. So a
92
- * declaration that simply did not mention `hasWiki` asserted "the wiki is ON", and one that
93
- * did not mention `description` asserted "the description is EMPTY" — and `updateForm` sent
94
- * both. Declaring a repo you only wanted to rename would have wiped its description and
95
- * switched features on, reported as a plain `update`. Only `defaultBranch` was written
96
- * correctly, on the line below, which is what the other five now copy.
124
+ * words). Declaring a repo you only wanted to rename leaves its description and feature
125
+ * toggles alone; dropping a line no longer resets a field.
97
126
  *
98
- * ⚠️ THE PRICE, STATED: dropping a line no longer resets a field. Clear one out of band in the
99
- * Forgejo UI or with a direct PATCH. It costs no forever-diff, because an undeclared field is
100
- * not compared either.
101
- *
102
- * ⚠️ `private` IS THE ONE WORTH DECLARING ANYWAY ON EVERY REPO. Leaving it undeclared now means
127
+ * ⚠️ `private` IS THE ONE WORTH DECLARING ANYWAY ON EVERY REPO. Leaving it undeclared means
103
128
  * Alchemy will not notice a repository being made public. That is the correct trade for a
104
129
  * generic family — undeclared is unmanaged, not assumed — but it is a real gap, so say it
105
130
  * explicitly in each declaration rather than relying on a default that no longer exists.
@@ -111,16 +136,11 @@ const handlers = forgejoHandlers<RepositoryProps, RepositoryAttributes>({
111
136
  (props.hasWiki === undefined || attributes.hasWiki === props.hasWiki) &&
112
137
  (props.hasProjects === undefined || attributes.hasProjects === props.hasProjects) &&
113
138
  (props.defaultBranch === undefined || attributes.defaultBranch === props.defaultBranch),
114
- path: (props) => `repos/${props.org}/${props.name}`,
115
- updateForm: (props) => ({
116
- ...(props.defaultBranch === undefined ? {} : { default_branch: props.defaultBranch }),
117
- ...(props.description === undefined ? {} : { description: props.description }),
118
- ...(props.hasIssues === undefined ? {} : { has_issues: props.hasIssues }),
119
- ...(props.hasProjects === undefined ? {} : { has_projects: props.hasProjects }),
120
- ...(props.hasWiki === undefined ? {} : { has_wiki: props.hasWiki }),
121
- ...(props.private === undefined ? {} : { private: props.private }),
122
- }),
123
- });
139
+ update: (props) =>
140
+ repository.editRepo({ owner: props.org, repo: props.name, ...editForm(props) }),
141
+ };
142
+
143
+ export const handlers = forgejoHandlers(spec);
124
144
 
125
145
  export const ForgejoRepositoryProvider = () =>
126
146
  Provider.effect(ForgejoRepository, Effect.succeed(ForgejoRepository.Provider.of(handlers)));
@@ -1,16 +1,33 @@
1
1
  /**
2
- * One shape for every Forgejo object — the same four operations, different path and JSON body.
2
+ * One shape for every Forgejo object — the same four operations, different distilled call.
3
3
  *
4
4
  * ⛔ THE ALTERNATIVE IS COPYING THE SAME HANDLERS PER RESOURCE. Repositories, org labels, teams,
5
- * runners, branch protection and package settings are all create / read / update / delete over
6
- * `/api/v1` with JSON bodies. Hand-rolling the fourth copy is where someone drops the read-back
7
- * or the isResolved guard and the plan lies.
5
+ * webhooks, branch protection and org secrets are all create / read / update / delete against
6
+ * `@distilled.cloud/forgejo`'s typed operations. Hand-rolling the fourth copy is where someone
7
+ * drops the read-back or the isResolved guard and the plan lies.
8
8
  *
9
- * ★ SO THE FOUR OPERATIONS LIVE HERE, ONCE. A new Forgejo object should be a spec plus ~forty
10
- * lines, not a hundred and thirty.
9
+ * ★ SO THE FOUR OPERATIONS LIVE HERE, ONCE. A new Forgejo object is a spec (which distilled
10
+ * operations to call) plus attribute mapping, not a hundred and thirty lines of its own.
11
11
  *
12
- * ⚠️ GITEA ANSWERS WITH THE OBJECT DIRECTLY — NOT `{"data": ...}`. See client.ts. reconcile still
13
- * READS BACK after every write rather than trusting the status code alone.
12
+ * 🔴 WHY THIS REPLACED `client.ts`'s HAND-ROLLED `HttpClient` CALLS. Measured against Tim's
13
+ * distilled research page 2026-09-23: upstream Alchemy only accepts providers that call
14
+ * `@distilled.cloud/<vendor>` operations and `catchTag` their typed errors — not a raw
15
+ * `fetch`/`HttpClient` client over a hand-maintained path table. `@distilled.cloud/forgejo`
16
+ * published at 1.0.0-rc.12 covers every operation this family calls (verified operation by
17
+ * operation while porting each resource file).
18
+ *
19
+ * ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE — the
20
+ * direct successor to the old, centralized `absentOn404`. `Effect.catchTag`'s tag-literal
21
+ * inference needs a concrete error union to resolve `"NotFound"` against; a spec's `E` is only
22
+ * concrete at each resource file's own call site (`RepoGetBranchProtectionError`, `GetRepoError`,
23
+ * …), not inside this shared engine's generic functions. So every `fetchLive` below ends
24
+ * `.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined)))` itself — one line,
25
+ * `catchTag`, never a status comparison — and this engine trusts the result is already folded.
26
+ *
27
+ * ⚠️ GITEA ANSWERS WITH THE OBJECT DIRECTLY — NOT `{"data": ...}`, and the distilled SDK decodes
28
+ * that shape already. `reconcile` still READS BACK after every write rather than trusting a
29
+ * write response alone — a create/update can 200 with a body this family does not fully trust
30
+ * until the follow-up `read`.
14
31
  *
15
32
  * ★ WHY `Forgejo.Repository` DECLARES `defaultRemovalPolicy: 'retain'`, WRITTEN ONCE HERE.
16
33
  * Alchemy's own `GitHub.Repository` defaults to retain "because their contents are
@@ -21,42 +38,36 @@
21
38
  */
22
39
  import { isResolved } from 'alchemy/Diff';
23
40
  import type { Input } from 'alchemy/Input';
41
+ import { CredentialsFromEnv } from '@distilled.cloud/forgejo/Credentials';
42
+ import type { ForgejoOpContext } from '@distilled.cloud/forgejo/Protocol';
24
43
  import * as Effect from 'effect/Effect';
25
44
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
26
- import { ForgejoError, forgejo } from './client.ts';
27
45
 
28
46
  /**
29
- * ★ WHAT EVERY OPERATION HERE NEEDS FROM THE RUNTIME — and it is NOT what the first draft said.
30
- *
31
- * These resources declared `ChildProcessSpawner`, copied from <estate>/proxmox, where it is there
32
- * because a PVE credential is minted by shelling out to `bao`. Nothing in this package shells
33
- * out: the token comes from `FORGEJO_TOKEN` in the environment. Declaring a service you never
34
- * use is not harmless — it makes a stack provide a layer for nothing, and it hides the one
35
- * service you DO need.
36
- *
37
- * What is actually required is `HttpClient`, for the reason in client.ts: Alchemy's own
38
- * providers call through Effect's client rather than the global `fetch`.
47
+ * ★ WHAT EVERY HANDLER NEEDS FROM THE CALLER'S RUNTIME, after Credentials are provided below.
48
+ * `Forgejo.ForgejoOpContext` is `Credentials | HttpClient.HttpClient`; `forgejoHandlers` closes
49
+ * over `CredentialsFromEnv` itself (same `FORGEJO_URL` / `FORGEJO_TOKEN` names client.ts read,
50
+ * still resolved at call time — see credentials.ts's own note on that), so a consuming stack
51
+ * only has to provide `HttpClient.HttpClient`, exactly as it did before this migration.
39
52
  */
40
53
  export type ForgejoRequirements = HttpClient.HttpClient;
41
54
 
42
- export type ForgejoSpec<Props extends object, Attributes> = {
43
- /** `repos/HomeFlare/homeflare`, `orgs/homeflare/labels/40` — one object. */
44
- readonly path: (props: Props) => string;
45
- /** `orgs/HomeFlare/repos`, `orgs/homeflare/labels` — where a new one is POSTed. */
46
- readonly collection: (props: Props) => string;
47
- readonly attributes: (live: Record<string, unknown>, props: Props) => Attributes | undefined;
48
- /** JSON body on create. ⚠️ Gitea takes JSON, not form encoding. */
49
- readonly createForm: (props: Props) => Record<string, unknown>;
50
- readonly updateForm?: (props: Props) => Record<string, unknown>;
55
+ /**
56
+ * One Forgejo object's distilled calls. `Live` is whatever the SDK decodes (a `Repository`, a
57
+ * `Label`, a composite for team membership, …) — no more untyped `Record<string, unknown>`.
58
+ *
59
+ * `E` is left to each resource file to declare (the union of the distilled operations' error
60
+ * types it actually calls, e.g. `RepoCreateHookError | RepoEditHookError | ...`), so a call site's
61
+ * precise per-operation error union flows straight through instead of being widened by hand here.
62
+ */
63
+ export type ForgejoSpec<Props extends object, Live, Attributes, E> = {
64
+ /** Already folds the SDK's `NotFound` to `undefined` via its own `catchTag` — see the note above. */
65
+ readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, ForgejoOpContext>;
66
+ readonly attributes: (live: Live, props: Props) => Attributes | undefined;
51
67
  readonly matches: (attributes: Attributes, props: Props) => boolean;
52
- /**
53
- * When the stable key is not enough for the wire path — org labels are addressed by numeric id.
54
- * `locate` lists or searches; `wirePath` builds update/delete paths from the live row.
55
- */
56
- readonly locate?: (
57
- props: Props,
58
- ) => Effect.Effect<Record<string, unknown> | undefined, ForgejoError, ForgejoRequirements>;
59
- readonly wirePath?: (props: Props, live: Record<string, unknown>) => string;
68
+ /** Absent for the two existence-only families (`OrgSecret`, `TeamMember`), which use `upsert` instead. */
69
+ readonly create?: (props: Props) => Effect.Effect<unknown, E, ForgejoOpContext>;
70
+ readonly update?: (props: Props, live: Live) => Effect.Effect<unknown, E, ForgejoOpContext>;
60
71
  /**
61
72
  * When create is PUT upsert rather than POST — org actions secrets have no POST and no GET-by-name.
62
73
  * `before === undefined` means absent; the callback decides whether to write. Existence-only families
@@ -64,36 +75,18 @@ export type ForgejoSpec<Props extends object, Attributes> = {
64
75
  */
65
76
  readonly upsert?: (
66
77
  props: Props,
67
- before: Record<string, unknown> | undefined,
68
- ) => Effect.Effect<void, ForgejoError, ForgejoRequirements>;
78
+ before: Live | undefined,
79
+ ) => Effect.Effect<void, E, ForgejoOpContext>;
80
+ readonly destroy: (props: Props, live: Live) => Effect.Effect<unknown, E, ForgejoOpContext>;
69
81
  };
70
82
 
71
- /** ⚠️ Only 404 folds to "absent". A 403 is a real failure — folding it would plan a create over an existing object. */
72
- const absentOn404 = <A>(io: Effect.Effect<A, ForgejoError, ForgejoRequirements>) =>
73
- io.pipe(
74
- Effect.catchIf(
75
- (cause): cause is ForgejoError => cause instanceof ForgejoError && cause.status === 404,
76
- () => Effect.succeed(undefined as A),
77
- ),
78
- );
79
-
80
- export const forgejoOperations = <Props extends object, Attributes>(
81
- spec: ForgejoSpec<Props, Attributes>,
83
+ export const forgejoOperations = <Props extends object, Live, Attributes, E>(
84
+ spec: ForgejoSpec<Props, Live, Attributes, E>,
82
85
  ) => {
83
- const fetchLive = (props: Props) =>
84
- absentOn404(
85
- spec.locate !== undefined
86
- ? spec.locate(props)
87
- : forgejo<Record<string, unknown>>('GET', spec.path(props)),
88
- );
89
-
90
- const objectPath = (props: Props, live: Record<string, unknown>) =>
91
- spec.wirePath === undefined ? spec.path(props) : spec.wirePath(props, live);
92
-
93
86
  const read = (props: Props) =>
94
- fetchLive(props).pipe(
95
- Effect.map((data) => (data === undefined ? undefined : spec.attributes(data, props))),
96
- );
87
+ spec
88
+ .fetchLive(props)
89
+ .pipe(Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))));
97
90
 
98
91
  return {
99
92
  read,
@@ -104,34 +97,36 @@ export const forgejoOperations = <Props extends object, Attributes>(
104
97
  const live = yield* read(news);
105
98
  if (live === undefined) return { action: 'update' } as const;
106
99
  if (spec.matches(live, news)) return { action: 'noop' } as const;
107
- return spec.updateForm === undefined
100
+ return spec.update === undefined
108
101
  ? ({ action: 'replace' } as const)
109
102
  : ({ action: 'update' } as const);
110
103
  }),
111
104
 
112
105
  reconcile: (news: Props) =>
113
106
  Effect.gen(function* () {
114
- const before = yield* fetchLive(news);
107
+ const before = yield* spec.fetchLive(news);
115
108
  if (spec.upsert !== undefined) {
116
109
  yield* spec.upsert(news, before);
117
110
  } else if (before === undefined) {
118
- yield* forgejo('POST', spec.collection(news), spec.createForm(news));
119
- } else if (spec.updateForm !== undefined) {
111
+ if (spec.create === undefined) {
112
+ return yield* Effect.die(
113
+ new Error('forgejoOperations: spec declares neither create nor upsert'),
114
+ );
115
+ }
116
+ yield* spec.create(news);
117
+ } else if (spec.update !== undefined) {
120
118
  const mapped = spec.attributes(before, news);
121
119
  if (mapped === undefined || spec.matches(mapped, news)) {
122
120
  /* adoption or already converged — no PATCH */
123
121
  } else {
124
- const form = spec.updateForm(news);
125
- if (Object.keys(form).length > 0) {
126
- yield* forgejo('PATCH', objectPath(news, before), form);
127
- }
122
+ yield* spec.update(news, before);
128
123
  }
129
124
  }
130
125
  const after = yield* read(news);
131
126
  if (after === undefined) {
132
127
  return yield* Effect.die(
133
128
  new Error(
134
- `${spec.path(news)}: the write returned no error but the object is still absent. ` +
129
+ 'the write returned no error but the object is still absent. ' +
135
130
  'Gitea returns the object directly — read back rather than trusting the status code.',
136
131
  ),
137
132
  );
@@ -141,9 +136,9 @@ export const forgejoOperations = <Props extends object, Attributes>(
141
136
 
142
137
  destroy: (olds: Props) =>
143
138
  Effect.gen(function* () {
144
- const live = yield* fetchLive(olds);
139
+ const live = yield* spec.fetchLive(olds);
145
140
  if (live === undefined) return;
146
- yield* forgejo('DELETE', objectPath(olds, live));
141
+ yield* spec.destroy(olds, live);
147
142
  }),
148
143
  };
149
144
  };
@@ -151,21 +146,27 @@ export const forgejoOperations = <Props extends object, Attributes>(
151
146
  /**
152
147
  * The five provider handlers for a spec'd Forgejo object, wired once.
153
148
  *
149
+ * ★ CREDENTIALS ARE PROVIDED HERE, ONCE, NOT BY EVERY CALLER. `CredentialsFromEnv` resolves
150
+ * `FORGEJO_URL` / `FORGEJO_TOKEN` on the calling fiber per request (credentials.ts's own note),
151
+ * so this closure keeps that laziness — nothing is captured at module load.
152
+ *
154
153
  * ⛔ IT STOPS AT THE HANDLERS AND DOES NOT RETURN THE LAYER — same reasoning as pveHandlers in
155
154
  * <estate>/proxmox: wrapping `Provider.effect` here needs casts against Alchemy's `Props<R>`.
156
155
  *
157
156
  * ⚠️ `list` answers empty — Forgejo index endpoints return the whole org; adoption stays explicit.
158
157
  */
159
- export const forgejoHandlers = <Props extends object, Attributes extends object>(
160
- spec: ForgejoSpec<Props, Attributes>,
158
+ export const forgejoHandlers = <Props extends object, Live, Attributes extends object, E>(
159
+ spec: ForgejoSpec<Props, Live, Attributes, E>,
161
160
  ) => {
162
161
  const ops = forgejoOperations(spec);
162
+ const withCredentials = <A>(effect: Effect.Effect<A, E, ForgejoOpContext>) =>
163
+ Effect.provide(effect, CredentialsFromEnv);
163
164
  return {
164
165
  list: () => Effect.succeed([]),
165
- read: ({ olds }: { olds: Props }) => ops.read(olds),
166
+ read: ({ olds }: { olds: Props }) => withCredentials(ops.read(olds)),
166
167
  diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
167
- ops.diff(news, output),
168
- reconcile: ({ news }: { news: Props }) => ops.reconcile(news),
169
- delete: ({ olds }: { olds: Props }) => ops.destroy(olds),
168
+ withCredentials(ops.diff(news, output)),
169
+ reconcile: ({ news }: { news: Props }) => withCredentials(ops.reconcile(news)),
170
+ delete: ({ olds }: { olds: Props }) => withCredentials(ops.destroy(olds)),
170
171
  };
171
172
  };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * `Forgejo.TeamMember`'s `fetchLive`: the one family in this set where a resource-level domain
3
+ * error (`ForgejoTeamNotFoundError` — "no team named X", not from the distilled SDK) sits
4
+ * alongside the SDK's own `NotFound`. Proves the two stay distinct: only the SDK's tag folds.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import * as organization from '@distilled.cloud/forgejo/organization';
8
+ import * as Effect from 'effect/Effect';
9
+ import { fakeFailure, fakeForgejo, fakeForgejoLayer } from './fake-forgejo.ts';
10
+ import { ForgejoTeamNotFoundError, spec } from './team-member.ts';
11
+
12
+ const TEAMS_PATH = '/api/v1/orgs/homeflare/teams';
13
+ const TEAMS_LIST_PATH = `${TEAMS_PATH}?limit=200`;
14
+ const MEMBER_PATH = (id: number) => `/api/v1/teams/${String(id)}/members/tim`;
15
+
16
+ const fetchMembership = (fetchFn: typeof globalThis.fetch, team: string) =>
17
+ Effect.gen(function* () {
18
+ const rows = yield* organization.orgListTeams({ limit: 200, org: 'homeflare' });
19
+ const id = rows.find((row) => row.name === team)?.id;
20
+ if (id === undefined) {
21
+ return yield* Effect.fail(
22
+ new ForgejoTeamNotFoundError({ message: `no team named ${team} in org homeflare` }),
23
+ );
24
+ }
25
+ return yield* organization.orgListTeamMember({ id, username: 'tim' });
26
+ }).pipe(
27
+ Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
28
+ Effect.provide(fakeForgejoLayer(fetchFn)),
29
+ );
30
+
31
+ describe('Forgejo.TeamMember fetchLive', () => {
32
+ test('a member not on the team (404 on the membership GET) folds to undefined', async () => {
33
+ const fake = fakeForgejo((method, url) =>
34
+ method === 'GET' && url.pathname === TEAMS_PATH
35
+ ? Response.json([{ id: 5, name: 'Owners' }])
36
+ : fakeFailure(404, 'member not found'),
37
+ );
38
+ const result = await Effect.runPromise(fetchMembership(fake.fetch, 'Owners'));
39
+ expect(result).toBeUndefined();
40
+ expect(fake.seen.map((s) => s.path)).toEqual([TEAMS_LIST_PATH, MEMBER_PATH(5)]);
41
+ });
42
+
43
+ test('a team the org has no record of is a real failure, not absence', async () => {
44
+ // ⛔ THE POINT OF THIS TEST. `ForgejoTeamNotFoundError` has its own `_tag` — it is not the
45
+ // SDK's `NotFound` — so the same `catchTag('NotFound', ...)` that just folded a missing
46
+ // membership above must NOT fold this. A team-name typo failing loudly beats it silently
47
+ // planning to add a member to a team that was never there.
48
+ const fake = fakeForgejo((method, url) =>
49
+ method === 'GET' && url.pathname === TEAMS_PATH ? Response.json([]) : fakeFailure(404, 'x'),
50
+ );
51
+ const failure = await Effect.runPromise(Effect.flip(fetchMembership(fake.fetch, 'Ghosts')));
52
+ expect(failure._tag).toBe('ForgejoTeamNotFoundError');
53
+ expect(failure.message).toContain('Ghosts');
54
+ expect(fake.seen.map((s) => s.path)).toEqual([TEAMS_LIST_PATH]);
55
+ });
56
+ });
57
+
58
+ describe('Forgejo.TeamMember spec — the actual exported production functions', () => {
59
+ // ⛔ Proves the real `fetchLive` + `attributes` in team-member.ts, not a parallel
60
+ // reimplementation — see the same note in repository.test.ts.
61
+ const props = { org: 'homeflare', team: 'Owners', username: 'tim' };
62
+
63
+ const readThrough = (fetchFn: typeof globalThis.fetch) =>
64
+ Effect.runPromise(
65
+ spec.fetchLive(props).pipe(
66
+ Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
67
+ Effect.provide(fakeForgejoLayer(fetchFn)),
68
+ ),
69
+ );
70
+
71
+ test('a member on the team reads through spec.fetchLive + spec.attributes', async () => {
72
+ const fake = fakeForgejo((method, url) => {
73
+ if (method === 'GET' && url.pathname === TEAMS_PATH) {
74
+ return Response.json([{ id: 5, name: 'Owners' }]);
75
+ }
76
+ if (method === 'GET' && url.pathname === MEMBER_PATH(5)) {
77
+ return Response.json({ id: 99, login: 'tim' });
78
+ }
79
+ return fakeFailure(404, 'not found');
80
+ });
81
+ expect(await readThrough(fake.fetch)).toEqual({
82
+ org: 'homeflare',
83
+ team: 'Owners',
84
+ teamId: 5,
85
+ username: 'tim',
86
+ });
87
+ });
88
+
89
+ test('a member not on the team through spec.fetchLive is undefined', async () => {
90
+ const fake = fakeForgejo((method, url) =>
91
+ method === 'GET' && url.pathname === TEAMS_PATH
92
+ ? Response.json([{ id: 5, name: 'Owners' }])
93
+ : fakeFailure(404, 'not a member'),
94
+ );
95
+ expect(await readThrough(fake.fetch)).toBeUndefined();
96
+ });
97
+ });