@homeflare/alchemy 0.27.4 → 0.29.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 (217) hide show
  1. package/README.md +2 -0
  2. package/dist/caddy/admin-address.d.ts +27 -0
  3. package/dist/caddy/admin-address.d.ts.map +1 -0
  4. package/dist/caddy/admin-calls.d.ts +25 -28
  5. package/dist/caddy/admin-calls.d.ts.map +1 -1
  6. package/dist/caddy/admin.d.ts +28 -38
  7. package/dist/caddy/admin.d.ts.map +1 -1
  8. package/dist/caddy/caddy-http-client.d.ts +25 -0
  9. package/dist/caddy/caddy-http-client.d.ts.map +1 -0
  10. package/dist/caddy/config-errors.d.ts +24 -0
  11. package/dist/caddy/config-errors.d.ts.map +1 -0
  12. package/dist/caddy/config-lifecycle.d.ts +20 -49
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -1
  14. package/dist/caddy/config-reconcile.d.ts +45 -0
  15. package/dist/caddy/config-reconcile.d.ts.map +1 -0
  16. package/dist/caddy/config.d.ts +4 -3
  17. package/dist/caddy/config.d.ts.map +1 -1
  18. package/dist/caddy/fake-caddy.d.ts +10 -2
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  20. package/dist/caddy/index.d.ts +3 -3
  21. package/dist/caddy/index.d.ts.map +1 -1
  22. package/dist/caddy/index.js +487 -444
  23. package/dist/caddy/index.js.map +16 -12
  24. package/dist/caddy/local-admin.d.ts +20 -34
  25. package/dist/caddy/local-admin.d.ts.map +1 -1
  26. package/dist/caddy/providers.d.ts +19 -3
  27. package/dist/caddy/providers.d.ts.map +1 -1
  28. package/dist/cloudflare/mesh-node-token.d.ts +1 -1
  29. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -1
  30. package/dist/forgejo/branch-protection.d.ts +4 -4
  31. package/dist/forgejo/org-actions-secrets.d.ts +4 -4
  32. package/dist/forgejo/org-label.d.ts +4 -4
  33. package/dist/forgejo/org-team.d.ts +4 -4
  34. package/dist/forgejo/repo-webhook.d.ts +4 -4
  35. package/dist/forgejo/repository.d.ts +4 -4
  36. package/dist/forgejo/team-member.d.ts +4 -4
  37. package/dist/github/declare-repo-baseline.d.ts +1 -1
  38. package/dist/github/declare-repo-baseline.d.ts.map +1 -1
  39. package/dist/github/index.d.ts +9 -5
  40. package/dist/github/index.d.ts.map +1 -1
  41. package/dist/github/index.js +161 -25
  42. package/dist/github/index.js.map +15 -13
  43. package/dist/github/repo-baseline-data.d.ts +10 -1
  44. package/dist/github/repo-baseline-data.d.ts.map +1 -1
  45. package/dist/github/repo-policy.d.ts +1 -1
  46. package/dist/github/repo-policy.d.ts.map +1 -1
  47. package/dist/github/repository-ruleset-errors.d.ts +37 -3
  48. package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
  49. package/dist/github/repository-ruleset-form.d.ts.map +1 -1
  50. package/dist/github/repository-ruleset-guards.d.ts +15 -2
  51. package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
  52. package/dist/github/repository-ruleset-narrowing-guards.d.ts +39 -0
  53. package/dist/github/repository-ruleset-narrowing-guards.d.ts.map +1 -0
  54. package/dist/github/repository-ruleset-octokit.d.ts +49 -0
  55. package/dist/github/repository-ruleset-octokit.d.ts.map +1 -1
  56. package/dist/github/repository-ruleset-probe.d.ts +2 -1
  57. package/dist/github/repository-ruleset-probe.d.ts.map +1 -1
  58. package/dist/github/repository-ruleset-props.d.ts +91 -0
  59. package/dist/github/repository-ruleset-props.d.ts.map +1 -0
  60. package/dist/github/repository-ruleset-providers.d.ts +42 -0
  61. package/dist/github/repository-ruleset-providers.d.ts.map +1 -0
  62. package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -1
  63. package/dist/github/repository-ruleset.d.ts +28 -34
  64. package/dist/github/repository-ruleset.d.ts.map +1 -1
  65. package/dist/litellm/fake-litellm.d.ts +27 -16
  66. package/dist/litellm/fake-litellm.d.ts.map +1 -1
  67. package/dist/litellm/fake-stack.d.ts +9 -4
  68. package/dist/litellm/fake-stack.d.ts.map +1 -1
  69. package/dist/litellm/index.d.ts +7 -5
  70. package/dist/litellm/index.d.ts.map +1 -1
  71. package/dist/litellm/index.js +53 -123
  72. package/dist/litellm/index.js.map +8 -8
  73. package/dist/litellm/operations.d.ts +60 -0
  74. package/dist/litellm/operations.d.ts.map +1 -0
  75. package/dist/litellm/pass-through-endpoint.d.ts +10 -28
  76. package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
  77. package/dist/litellm/pass-through-errors.d.ts +37 -0
  78. package/dist/litellm/pass-through-errors.d.ts.map +1 -0
  79. package/dist/litellm/pass-through-form.d.ts +11 -4
  80. package/dist/litellm/pass-through-form.d.ts.map +1 -1
  81. package/dist/litellm/providers.d.ts +7 -5
  82. package/dist/litellm/providers.d.ts.map +1 -1
  83. package/dist/netbox/prefix.d.ts +4 -4
  84. package/dist/paperless/custom-field.d.ts +21 -21
  85. package/dist/paperless/custom-field.d.ts.map +1 -1
  86. package/dist/paperless/document-type.d.ts +34 -4
  87. package/dist/paperless/document-type.d.ts.map +1 -1
  88. package/dist/paperless/fake-paperless.d.ts +36 -19
  89. package/dist/paperless/fake-paperless.d.ts.map +1 -1
  90. package/dist/paperless/index.d.ts +7 -2
  91. package/dist/paperless/index.d.ts.map +1 -1
  92. package/dist/paperless/index.js +164 -228
  93. package/dist/paperless/index.js.map +10 -13
  94. package/dist/paperless/matching-locate.d.ts +42 -20
  95. package/dist/paperless/matching-locate.d.ts.map +1 -1
  96. package/dist/paperless/matching-types.d.ts +47 -18
  97. package/dist/paperless/matching-types.d.ts.map +1 -1
  98. package/dist/paperless/matching.d.ts +30 -27
  99. package/dist/paperless/matching.d.ts.map +1 -1
  100. package/dist/paperless/providers.d.ts +13 -7
  101. package/dist/paperless/providers.d.ts.map +1 -1
  102. package/dist/paperless/storage-path.d.ts +34 -4
  103. package/dist/paperless/storage-path.d.ts.map +1 -1
  104. package/dist/paperless/tag.d.ts +43 -8
  105. package/dist/paperless/tag.d.ts.map +1 -1
  106. package/dist/proxmox/acl.d.ts +30 -23
  107. package/dist/proxmox/acl.d.ts.map +1 -1
  108. package/dist/proxmox/distilled-pve.d.ts +89 -0
  109. package/dist/proxmox/distilled-pve.d.ts.map +1 -0
  110. package/dist/proxmox/index.js +663 -609
  111. package/dist/proxmox/index.js.map +12 -11
  112. package/dist/proxmox/members.d.ts +29 -10
  113. package/dist/proxmox/members.d.ts.map +1 -1
  114. package/docs/caddy-admin.md +1 -1
  115. package/docs/caddy.md +3 -2
  116. package/docs/credentials.md +16 -0
  117. package/docs/litellm.md +21 -13
  118. package/docs/paperless.md +12 -0
  119. package/docs/peers.md +5 -0
  120. package/docs/repo-policy.md +16 -1
  121. package/docs/repository-ruleset.md +53 -13
  122. package/docs/upstream-conformance.md +126 -10
  123. package/package.json +9 -2
  124. package/src/caddy/admin-address.ts +71 -0
  125. package/src/caddy/admin-calls.ts +48 -100
  126. package/src/caddy/admin.ts +37 -41
  127. package/src/caddy/adopt-scope.test.ts +1 -1
  128. package/src/caddy/caddy-http-client.ts +229 -0
  129. package/src/caddy/config-adopt-provider.test.ts +139 -0
  130. package/src/caddy/config-adopt.test.ts +60 -126
  131. package/src/caddy/config-errors.ts +47 -0
  132. package/src/caddy/config-lifecycle.test.ts +55 -42
  133. package/src/caddy/config-lifecycle.ts +72 -162
  134. package/src/caddy/config-reconcile.ts +138 -0
  135. package/src/caddy/config.ts +36 -42
  136. package/src/caddy/fake-caddy.ts +20 -2
  137. package/src/caddy/index.ts +3 -8
  138. package/src/caddy/local-admin.test.ts +35 -21
  139. package/src/caddy/local-admin.ts +60 -167
  140. package/src/caddy/providers.test.ts +14 -6
  141. package/src/caddy/providers.ts +20 -4
  142. package/src/github/declare-repo-baseline.ts +6 -6
  143. package/src/github/index.ts +10 -4
  144. package/src/github/repo-baseline-data.test.ts +13 -0
  145. package/src/github/repo-baseline-data.ts +11 -2
  146. package/src/github/repo-policy-stack.test.ts +59 -0
  147. package/src/github/repo-policy.ts +3 -2
  148. package/src/github/repository-ruleset-errors.ts +51 -4
  149. package/src/github/repository-ruleset-exact-adopt-k1.test.ts +109 -0
  150. package/src/github/repository-ruleset-exact-adopt.test.ts +184 -0
  151. package/src/github/repository-ruleset-form.ts +7 -3
  152. package/src/github/repository-ruleset-guards.ts +11 -4
  153. package/src/github/repository-ruleset-narrowing-guards.ts +141 -0
  154. package/src/github/repository-ruleset-narrowing.test.ts +210 -0
  155. package/src/github/repository-ruleset-octokit.test.ts +141 -0
  156. package/src/github/repository-ruleset-octokit.ts +121 -30
  157. package/src/github/repository-ruleset-probe.ts +2 -1
  158. package/src/github/repository-ruleset-props.ts +95 -0
  159. package/src/github/repository-ruleset-providers.ts +46 -0
  160. package/src/github/repository-ruleset-reconcile.ts +35 -0
  161. package/src/github/repository-ruleset-wire.test.ts +38 -27
  162. package/src/github/repository-ruleset-write-refusals.test.ts +6 -26
  163. package/src/github/repository-ruleset.ts +53 -43
  164. package/src/litellm/fake-litellm.ts +83 -78
  165. package/src/litellm/fake-stack.ts +13 -5
  166. package/src/litellm/index.ts +7 -21
  167. package/src/litellm/operations.ts +149 -0
  168. package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +17 -20
  169. package/src/litellm/pass-through-endpoint-replace.test.ts +3 -4
  170. package/src/litellm/pass-through-endpoint.test.ts +8 -8
  171. package/src/litellm/pass-through-endpoint.ts +21 -31
  172. package/src/litellm/pass-through-errors.ts +37 -0
  173. package/src/litellm/pass-through-form.ts +20 -10
  174. package/src/litellm/providers.ts +8 -7
  175. package/src/paperless/custom-field.test.ts +49 -40
  176. package/src/paperless/custom-field.ts +57 -33
  177. package/src/paperless/document-type.test.ts +48 -0
  178. package/src/paperless/document-type.ts +51 -22
  179. package/src/paperless/fake-paperless.ts +63 -72
  180. package/src/paperless/index.ts +7 -13
  181. package/src/paperless/matching-locate.test.ts +102 -0
  182. package/src/paperless/matching-locate.ts +80 -86
  183. package/src/paperless/matching-types.ts +49 -16
  184. package/src/paperless/matching.ts +77 -90
  185. package/src/paperless/providers.ts +14 -10
  186. package/src/paperless/storage-path.test.ts +56 -0
  187. package/src/paperless/storage-path.ts +53 -24
  188. package/src/paperless/tag-identity.test.ts +127 -0
  189. package/src/paperless/tag.test.ts +100 -0
  190. package/src/paperless/tag.ts +62 -32
  191. package/src/proxmox/acl.test.ts +145 -0
  192. package/src/proxmox/acl.ts +128 -150
  193. package/src/proxmox/distilled-pve.test.ts +231 -0
  194. package/src/proxmox/distilled-pve.ts +147 -0
  195. package/src/proxmox/members-timeout.test.ts +161 -0
  196. package/src/proxmox/members.test.ts +9 -0
  197. package/src/proxmox/members.ts +36 -2
  198. package/dist/litellm/client.d.ts +0 -76
  199. package/dist/litellm/client.d.ts.map +0 -1
  200. package/dist/litellm/credentials.d.ts +0 -48
  201. package/dist/litellm/credentials.d.ts.map +0 -1
  202. package/dist/litellm/generated/pass-through.d.ts +0 -71
  203. package/dist/litellm/generated/pass-through.d.ts.map +0 -1
  204. package/dist/paperless/client.d.ts +0 -37
  205. package/dist/paperless/client.d.ts.map +0 -1
  206. package/dist/paperless/credentials.d.ts +0 -40
  207. package/dist/paperless/credentials.d.ts.map +0 -1
  208. package/dist/paperless/errors.d.ts +0 -49
  209. package/dist/paperless/errors.d.ts.map +0 -1
  210. package/src/litellm/client.ts +0 -195
  211. package/src/litellm/credentials.ts +0 -73
  212. package/src/litellm/generated/pass-through.ts +0 -73
  213. package/src/paperless/client.ts +0 -88
  214. package/src/paperless/credentials.ts +0 -69
  215. package/src/paperless/errors.ts +0 -62
  216. package/src/paperless/matching-identity.test.ts +0 -194
  217. package/src/paperless/matching.test.ts +0 -128
@@ -1,59 +1,73 @@
1
1
  /**
2
- * A fake Paperless-ngx for the tests beside it — `Bun.serve` on an ephemeral 127.0.0.1 port, the
3
- * same shape `../openbao/fake-bao.ts` and `../caddy/fake-caddy.ts` use.
2
+ * A minimal in-memory Paperless-ngx REST API for this family's tests — an in-memory `fetch`
3
+ * handed to Effect's real `FetchHttpClient` through its `Fetch` reference, mirroring
4
+ * `../netbox/fake-netbox.ts` and `../forgejo/fake-forgejo.ts`. Tests exercise distilled's REAL
5
+ * path assembly, JSON encode/decode and status→typed-error matching (`404` → `NotFound`, `403` →
6
+ * `Forbidden`, `400` → `BadRequest`, …) — nothing about that matching is re-implemented here, only
7
+ * the wire responses and in-memory rows a scenario needs.
4
8
  *
5
- * ⛔ TEST-ONLY. No provider imports this file. It stores rows in memory for exactly the four
6
- * taxonomy collections and answers `name__iexact` the way Django does — case-insensitive,
7
- * never collapsing two differently-cased rows into one.
9
+ * ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. Stores rows in memory for the four
10
+ * taxonomy collections and answers `name__iexact` the way Django does — case-insensitive, never
11
+ * collapsing two differently-cased rows into one. Ported from the pre-migration `Bun.serve`
12
+ * fake: production now goes through `@distilled.cloud/paperless-ngx`'s `CredentialsFromEnv`,
13
+ * which resolves through Effect `Config` and snapshots `process.env` once (measured in the
14
+ * forgejo migration, kit PR 180) — a test can no longer point production code at a fake server
15
+ * by mutating env vars, so this hands an explicit `credentials()` layer instead.
8
16
  */
17
+ import { type Credentials, credentials } from '@distilled.cloud/paperless-ngx/Credentials';
18
+ import type { PaperlessNgxOpContext } from '@distilled.cloud/paperless-ngx/Protocol';
9
19
  import * as Effect from 'effect/Effect';
20
+ import * as Layer from 'effect/Layer';
10
21
  import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
11
- import type { PaperlessRequirements } from './client.ts';
12
- import { type PaperlessCredentialsError, environmentLayer } from './credentials.ts';
13
- import type { PaperlessError } from './errors.ts';
14
22
 
15
- export const FAKE_TOKEN = 'fake-token-not-real';
23
+ export const FAKE_BASE = 'https://paperless.example.com';
24
+ export const FAKE_TOKEN = 'placeholder-paperless-token';
16
25
 
17
26
  export interface Seen {
18
27
  readonly method: string;
19
28
  readonly path: string;
20
- readonly headers: Headers;
21
29
  readonly body: string;
22
30
  }
23
31
 
24
32
  export type Row = Record<string, unknown> & { id: number };
25
33
 
26
34
  const COLLECTIONS = ['tags', 'document_types', 'storage_paths', 'custom_fields'] as const;
35
+ type Collection = (typeof COLLECTIONS)[number];
27
36
 
28
37
  export interface FakePaperless {
29
- readonly url: string;
38
+ readonly fetch: typeof globalThis.fetch;
30
39
  readonly seen: Seen[];
31
- readonly rows: (collection: (typeof COLLECTIONS)[number]) => readonly Row[];
32
- /** `undefined` to answer normally; a status to force it once per subsequent call. */
40
+ readonly rows: (collection: Collection) => readonly Row[];
41
+ /** `undefined` to answer normally; a status to force it once for the NEXT call only. */
33
42
  forceStatus: number | undefined;
34
- stop(): void;
35
43
  }
36
44
 
37
- const nextId = (store: Row[]): number =>
45
+ const nextId = (store: readonly Row[]): number =>
38
46
  store.length === 0 ? 1 : Math.max(...store.map((r) => r.id)) + 1;
39
47
 
48
+ const json = (status: number, body: unknown): Response =>
49
+ new Response(status === 204 ? null : JSON.stringify(body), {
50
+ headers: { 'content-type': 'application/json' },
51
+ status,
52
+ });
53
+
54
+ /**
55
+ * A stateful CRUD router across the four collections — this is what makes it possible to test
56
+ * `matching.ts`'s full create → read-back → PATCH → DELETE lifecycle, not just one canned
57
+ * response per test the way `fakeNetbox`/`fakeForgejo`'s simpler `route` callback does.
58
+ */
40
59
  export const fakePaperless = (): FakePaperless => {
41
60
  const seen: Seen[] = [];
42
- const store = new Map<(typeof COLLECTIONS)[number], Row[]>(COLLECTIONS.map((c) => [c, []]));
61
+ const store = new Map<Collection, Row[]>(COLLECTIONS.map((c) => [c, []]));
43
62
  let forced: number | undefined;
44
63
 
45
- const fetch = async (request: Request): Promise<Response> => {
64
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
65
+ const request =
66
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
46
67
  const url = new URL(request.url);
47
68
  const body = await request.text();
48
- seen.push({
49
- body,
50
- headers: request.headers,
51
- method: request.method,
52
- path: `${url.pathname}${url.search}`,
53
- });
54
-
55
- const token = request.headers.get('Authorization');
56
- if (token !== `Token ${FAKE_TOKEN}`) return json(401, { detail: 'Invalid token.' });
69
+ seen.push({ body, method: request.method, path: `${url.pathname}${url.search}` });
70
+
57
71
  if (forced !== undefined) {
58
72
  const status = forced;
59
73
  forced = undefined;
@@ -62,7 +76,7 @@ export const fakePaperless = (): FakePaperless => {
62
76
 
63
77
  const segments = url.pathname.split('/').filter((s) => s !== '');
64
78
  // ['api', collection, (id)?]
65
- const collection = segments[1] as (typeof COLLECTIONS)[number] | undefined;
79
+ const collection = segments[1] as Collection | undefined;
66
80
  if (collection === undefined || !COLLECTIONS.includes(collection))
67
81
  return json(404, { detail: 'Not found.' });
68
82
  const rows = store.get(collection) as Row[];
@@ -96,10 +110,10 @@ export const fakePaperless = (): FakePaperless => {
96
110
  return new Response(null, { status: 204 });
97
111
  }
98
112
  return json(405, { detail: 'method not allowed' });
99
- };
113
+ }) as typeof globalThis.fetch;
100
114
 
101
- const server = Bun.serve({ fetch, hostname: '127.0.0.1', port: 0 });
102
115
  return {
116
+ fetch,
103
117
  get forceStatus() {
104
118
  return forced;
105
119
  },
@@ -108,49 +122,26 @@ export const fakePaperless = (): FakePaperless => {
108
122
  },
109
123
  rows: (collection) => store.get(collection) ?? [],
110
124
  seen,
111
- stop: () => void server.stop(true),
112
- url: server.url.origin,
113
125
  };
114
126
  };
115
127
 
116
- const json = (status: number, body: unknown): Response =>
117
- new Response(status === 204 ? null : JSON.stringify(body), {
118
- headers: { 'content-type': 'application/json' },
119
- status,
120
- });
121
-
122
- /** Run `body` against a fresh fake, `PAPERLESS_URL`/`PAPERLESS_TOKEN` set only for its duration. */
123
- export const withFake = async (body: (fake: FakePaperless) => Promise<void>): Promise<void> => {
124
- const fake = fakePaperless();
125
- const savedUrl = process.env['PAPERLESS_URL'];
126
- const savedToken = process.env['PAPERLESS_TOKEN'];
127
- process.env['PAPERLESS_URL'] = fake.url;
128
- process.env['PAPERLESS_TOKEN'] = FAKE_TOKEN;
129
- try {
130
- await body(fake);
131
- } finally {
132
- fake.stop();
133
- if (savedUrl === undefined) delete process.env['PAPERLESS_URL'];
134
- else process.env['PAPERLESS_URL'] = savedUrl;
135
- if (savedToken === undefined) delete process.env['PAPERLESS_TOKEN'];
136
- else process.env['PAPERLESS_TOKEN'] = savedToken;
137
- }
138
- };
139
-
140
- /** Run an effect through the real `FetchHttpClient` and the real environment credentials layer. */
141
- export const run = <A>(
142
- effect: Effect.Effect<A, PaperlessError | PaperlessCredentialsError, PaperlessRequirements>,
143
- ) =>
144
- Effect.runPromise(
145
- effect.pipe(Effect.provide(FetchHttpClient.layer), Effect.provide(environmentLayer)),
146
- );
147
-
148
- export const runFailure = <A>(
149
- effect: Effect.Effect<A, PaperlessError | PaperlessCredentialsError, PaperlessRequirements>,
150
- ) =>
151
- Effect.runPromise(
152
- Effect.flip(effect).pipe(
153
- Effect.provide(FetchHttpClient.layer),
154
- Effect.provide(environmentLayer),
155
- ),
156
- );
128
+ /**
129
+ * distilled Credentials + the real FetchHttpClient over the fake — `PaperlessNgxOpContext`, what
130
+ * `matchingOperations(spec)` (matching.ts, unwrapped — not `matchingHandlers`, which bakes in
131
+ * `CredentialsFromEnv` and so cannot be pointed at a fake server, per this file's own header)
132
+ * needs to run against a fake.
133
+ */
134
+ export const fakePaperlessLayer = (
135
+ fetchFn: typeof globalThis.fetch,
136
+ creds: Layer.Layer<Credentials> = credentials({ baseUrl: FAKE_BASE, token: FAKE_TOKEN }),
137
+ ) => Layer.mergeAll(creds, FetchHttpClient.layer, Layer.succeed(FetchHttpClient.Fetch, fetchFn));
138
+
139
+ export const run = <A, E>(
140
+ effect: Effect.Effect<A, E, PaperlessNgxOpContext>,
141
+ fetchFn: typeof globalThis.fetch,
142
+ ) => Effect.runPromise(effect.pipe(Effect.provide(fakePaperlessLayer(fetchFn))));
143
+
144
+ export const runFailure = <A, E>(
145
+ effect: Effect.Effect<A, E, PaperlessNgxOpContext>,
146
+ fetchFn: typeof globalThis.fetch,
147
+ ) => Effect.runPromise(Effect.flip(effect).pipe(Effect.provide(fakePaperlessLayer(fetchFn))));
@@ -5,20 +5,14 @@
5
5
  * header. `matching.ts`'s internals and the raw generated modules are not re-exported; a
6
6
  * consumer wanting the same guard a Resource uses gets it through `constraintsFor`/
7
7
  * `bodyViolations`, not by importing `generated/` directly.
8
+ *
9
+ * 🔴 `PaperlessCredentials`/`PaperlessError` AND FRIENDS ARE GONE (2026-09-24, the
10
+ * `@distilled.cloud/paperless-ngx` migration — matching.ts's header), the same way
11
+ * `../netbox/index.ts` dropped `NetboxError`/`apiBase` in its own migration. A caller that
12
+ * needs the typed errors now imports them from `@distilled.cloud/paperless-ngx` directly (e.g.
13
+ * `@distilled.cloud/paperless-ngx/tags`'s `NotFound`); nothing in this estate imported the old
14
+ * names (measured 2026-09-24).
8
15
  */
9
- export {
10
- PaperlessCredentials,
11
- type PaperlessCredentialValue,
12
- PaperlessCredentialsError,
13
- environmentLayer as paperlessEnvironmentLayer,
14
- } from './credentials.ts';
15
- export {
16
- PaperlessNotFound,
17
- PaperlessUnauthorized,
18
- PaperlessValidation,
19
- PaperlessUnavailable,
20
- type PaperlessError,
21
- } from './errors.ts';
22
16
  export {
23
17
  PAPERLESS_CONSTRAINTS,
24
18
  bodyViolations,
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `soleMatch`, `locateOne` and the PR 163 identity switch (`fetchLive`) — identity resolution, in
3
+ * isolation. Pure/Effect only (no HTTP, `R = never`): every fixture is `Effect.succeed`, mirroring
4
+ * `../netbox/resource.test.ts`'s own `locateOne` tests. `tag.test.ts`/`tag-identity.test.ts`
5
+ * exercise the real distilled wire path this feeds from, plus the full PR 163 regression suite
6
+ * this file does not repeat.
7
+ *
8
+ * ⛔ THE FAILURE `soleMatch` GUARDS IS SILENT AND SELF-RENEWING. If an ambiguous match took the
9
+ * first row, adopt would bind to it, the next plan would bind to the other, and every plan
10
+ * after that would report drift that is not there. Failing loudly is the only outcome an
11
+ * operator can act on. Ported verbatim from the pre-migration `client.test.ts`.
12
+ */
13
+ import { describe, expect, test } from 'bun:test';
14
+ import * as Effect from 'effect/Effect';
15
+ import { LOCATE_PAGE, fetchLive, locateOne, soleMatch } from './matching-locate.ts';
16
+
17
+ describe('soleMatch', () => {
18
+ test('no candidate is absent, not an error — that is what plans a create', () => {
19
+ expect(soleMatch([], 'tags Invoices')).toBeUndefined();
20
+ });
21
+
22
+ test('exactly one candidate is the object', () => {
23
+ expect(soleMatch([{ id: 7 }], 'tags Invoices')).toEqual({ id: 7 });
24
+ });
25
+
26
+ /** ⛔ Two rows means the identity rule is wrong, and the message says which object it was. */
27
+ test('more than one candidate throws and names the object', () => {
28
+ expect(() => soleMatch([{ id: 7 }, { id: 8 }], 'tags Invoices')).toThrow(
29
+ /tags Invoices matched 2 Paperless objects/,
30
+ );
31
+ });
32
+ });
33
+
34
+ interface Row {
35
+ readonly id: number;
36
+ readonly name: string;
37
+ }
38
+
39
+ const page = (results: readonly Row[]) => Effect.succeed({ count: results.length, results });
40
+
41
+ describe('locateOne', () => {
42
+ test('an empty page is absent', async () => {
43
+ expect(await Effect.runPromise(locateOne(page([]), 'tags Invoices'))).toBeUndefined();
44
+ });
45
+
46
+ test('one row with no `identifies` narrowing is the object', async () => {
47
+ const row = await Effect.runPromise(
48
+ locateOne(page([{ id: 7, name: 'Invoices' }]), 'tags Invoices'),
49
+ );
50
+ expect(row).toEqual({ id: 7, name: 'Invoices' });
51
+ });
52
+
53
+ test('`identifies` narrows several server-side matches to the declared one', async () => {
54
+ const rows = [
55
+ { id: 7, name: 'Invoices' },
56
+ { id: 8, name: 'invoices' },
57
+ ];
58
+ const row = await Effect.runPromise(
59
+ locateOne(page(rows), 'tags Invoices', (r) => r.name === 'invoices'),
60
+ );
61
+ expect(row).toEqual({ id: 8, name: 'invoices' });
62
+ });
63
+
64
+ test('more rows than the page width is a defect, not a truncation', async () => {
65
+ const rows = Array.from({ length: LOCATE_PAGE + 1 }, (_, i) => ({ id: i, name: 'x' }));
66
+ await expect(Effect.runPromise(locateOne(page(rows), 'tags Invoices'))).rejects.toThrow(
67
+ /matched 21 rows, more than the 20-row page/,
68
+ );
69
+ });
70
+ });
71
+
72
+ describe('fetchLive (the PR 163 identity switch)', () => {
73
+ const byName = (rows: readonly Row[]) => (props: { name: string }) =>
74
+ locateOne(page(rows), `tags ${props.name}`, (r) => r.name === props.name);
75
+ const byId = (rows: readonly Row[]) => (id: number) =>
76
+ Effect.succeed(rows.find((r) => r.id === id));
77
+ const describe_ = (props: { name: string }) => `tags ${props.name}`;
78
+
79
+ test('no output: locates by name', async () => {
80
+ const rows = [{ id: 7, name: 'Invoices' }];
81
+ const live = await Effect.runPromise(
82
+ fetchLive({ name: 'Invoices' }, undefined, byName(rows), byId(rows), describe_),
83
+ );
84
+ expect(live?.id).toBe(7);
85
+ });
86
+
87
+ test('output present: locates by id, ignoring a since-changed name', async () => {
88
+ const rows = [{ id: 7, name: 'Renamed' }];
89
+ const live = await Effect.runPromise(
90
+ fetchLive({ name: 'Invoices' }, { id: 7 }, byName(rows), byId(rows), describe_),
91
+ );
92
+ expect(live?.id).toBe(7);
93
+ });
94
+
95
+ test('output.id no longer live: refuses rather than falling through to a create', async () => {
96
+ await expect(
97
+ Effect.runPromise(
98
+ fetchLive({ name: 'Invoices' }, { id: 7 }, byName([]), byId([]), describe_),
99
+ ),
100
+ ).rejects.toThrow(/no longer exists live|deleted out of band/);
101
+ });
102
+ });
@@ -4,101 +4,95 @@
4
4
  * live Paperless row IS this declaration — by name while there is no state, by the stored
5
5
  * `output.id` once there is one. See `matching.ts`'s header for the PR 163 red-team HIGH finding
6
6
  * this identity switch fixes.
7
+ *
8
+ * ★ EVERY EXPORT HERE IS GENERIC OVER `R`, NOT TIED TO `PaperlessNgxOpContext` — mirroring
9
+ * `../netbox/resource.ts`'s standalone `locateOne`. `matching.ts` wires these against a real
10
+ * spec's `PaperlessNgxOpContext`-typed operations; `matching-locate.test.ts` wires them against
11
+ * plain `Effect.succeed` fixtures (`R = never`) — the same function, no fake HTTP needed to
12
+ * prove the pure rule.
13
+ *
14
+ * ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `spec.getById`, NOT HERE —
15
+ * the direct successor to the old, centralized `absentOn404`, and the same split
16
+ * `../netbox/resource.ts` and `../forgejo/resource.ts` use. `Effect.catchTag`'s tag-literal
17
+ * inference needs a concrete error union to resolve `"NotFound"` against; a spec's `E` is only
18
+ * concrete at each resource file's own call site (`GetTagError`, `GetDocumentTypeError`, …). A
19
+ * LIST call is never folded here either — an empty `results` page is a normal 200, not a 404
20
+ * (measured live, kit PR 194).
7
21
  */
8
22
  import * as Effect from 'effect/Effect';
9
- import { PaperlessNotFound } from './errors.ts';
10
- import { type PaperlessList, type PaperlessRow, paperless } from './client.ts';
11
- import type { MatchingError, MatchingProps, MatchingRequirements } from './matching-types.ts';
12
23
 
13
24
  /**
14
- * ⚠️ Only 404 folds to "absent" — a 401/403 is never read as absent (acceptance criteria).
15
- * Exported — `matching.ts`'s `destroy` uses it directly around its own DELETE call.
25
+ * ★ Wide enough to see every case-variant candidate `name__iexact` returns; a natural key past it
26
+ * is not one. Unchanged from the pre-migration `client.ts`'s own value.
16
27
  */
17
- export const absentOn404 = <A>(io: Effect.Effect<A, MatchingError, MatchingRequirements>) =>
18
- io.pipe(
19
- Effect.catchIf(
20
- (cause): cause is PaperlessNotFound => cause instanceof PaperlessNotFound,
21
- () => Effect.succeed(undefined as A),
22
- ),
23
- );
24
-
25
- /** ★ Wide enough to see every case-variant candidate `name__iexact` returns; a natural key past it is not one. */
26
- const LOCATE_PAGE = 20;
27
-
28
- /** ⚠️ Exported — `matching.ts`'s `patchBody`/`matches` compare the live `owner` too (PR 163). */
29
- export const ownerOf = (row: PaperlessRow): number | undefined =>
30
- typeof row['owner'] === 'number' ? row['owner'] : undefined;
28
+ export const LOCATE_PAGE = 20;
31
29
 
32
30
  /**
33
- * The name/id lookups every `matching.ts` operation shares, built once per family spec.
34
- * `collection` and `describe` are all this slice needs out of a full `MatchingSpec`.
31
+ * The single row a filtered list returns, or `undefined`.
32
+ *
33
+ * ⛔ MORE THAN ONE MATCH IS A DEFECT, NOT A REASON TO TAKE THE FIRST. If an ambiguous match took
34
+ * the first row, adopt would bind to it, the next plan would bind to the other, and every plan
35
+ * after that would report drift that is not there — while "fixing" it PATCHed one object with
36
+ * the other's declaration. Failing loudly is the only outcome an operator can act on.
35
37
  */
36
- export const matchingLocate = <Props extends MatchingProps>(spec: {
37
- readonly collection: string;
38
- readonly describe: (props: Props) => string;
39
- }) => {
40
- /**
41
- * ★ THE NO-STATE LOCATE. Used only when there is no `output` yet: a genuine first create, or
42
- * the engine's own adoption probe (`provider.read`, always called with `output: undefined`).
43
- */
44
- const fetchByName = (props: Props) =>
45
- absentOn404(
46
- paperless<PaperlessList<PaperlessRow>>(
47
- 'GET',
48
- `${spec.collection}/?name__iexact=${encodeURIComponent(props.name)}&page_size=${String(LOCATE_PAGE)}`,
49
- ),
50
- ).pipe(
51
- Effect.map((list) => {
52
- if (list !== undefined && list.count > LOCATE_PAGE) {
53
- throw new Error(
54
- `${spec.describe(props)}: name__iexact matched ${String(list.count)} rows, more than ` +
55
- `the ${String(LOCATE_PAGE)}-row page this reads. Narrow the name rather than paging.`,
56
- );
57
- }
58
- const rows = (list?.results ?? []).filter(
59
- (row) => row['name'] === props.name && ownerOf(row) === (props.owner ?? undefined),
60
- );
61
- if (rows.length > 1) {
62
- throw new Error(
63
- `${spec.describe(props)}: matched ${String(rows.length)} Paperless objects with the same (name, owner).`,
64
- );
65
- }
66
- return rows[0];
67
- }),
38
+ export const soleMatch = <T>(rows: readonly T[], describe: string): T | undefined => {
39
+ if (rows.length > 1) {
40
+ throw new Error(
41
+ `${describe} matched ${String(rows.length)} Paperless objects with the same (name, owner).`,
68
42
  );
43
+ }
44
+ return rows[0];
45
+ };
69
46
 
70
- /** The stored id itself, once this declaration has one. A 404 here means the row is gone. */
71
- const fetchById = (id: number) =>
72
- absentOn404(paperless<PaperlessRow>('GET', `${spec.collection}/${String(id)}/`));
73
-
74
- /**
75
- * ⛔ THE IDENTITY SWITCH (PR 163, red-team HIGH finding — see `matching.ts`'s header). `output`
76
- * defined means this declaration has already written something: identity is `output.id`, full
77
- * stop, and a changed `name`/`owner` in `props` is drift to PATCH, never a reason to relocate.
78
- * `output` undefined means there is nothing to be wrong about yet — locate by name.
79
- */
80
- const fetchLive = <Attributes extends { readonly id: number }>(
81
- props: Props,
82
- output: Attributes | undefined,
83
- ) =>
84
- output === undefined
85
- ? fetchByName(props)
86
- : fetchById(output.id).pipe(
87
- Effect.flatMap((live) =>
88
- live === undefined
89
- ? Effect.die(
90
- new Error(
91
- `${spec.describe(props)}: id ${String(output.id)} is recorded in state but no ` +
92
- 'longer exists live (deleted out of band). Refusing to create a replacement ' +
93
- '— adopt the live object back under this declaration, or remove it from ' +
94
- 'state, before deploying again.',
95
- ),
96
- )
97
- : Effect.succeed(live),
98
- ),
47
+ /**
48
+ * List, then narrow to at most one candidate. Any Paperless resource whose locate is "filter
49
+ * server-side, then disambiguate in this process" reaches this once rather than reimplementing
50
+ * the page-width assertion and the ambiguity defect per resource.
51
+ */
52
+ export const locateOne = <Live, E, R>(
53
+ list: Effect.Effect<{ readonly count: number; readonly results: readonly Live[] }, E, R>,
54
+ describe: string,
55
+ identifies?: (row: Live) => boolean,
56
+ ): Effect.Effect<Live | undefined, E, R> =>
57
+ list.pipe(
58
+ Effect.map((page) => {
59
+ if (page.count > LOCATE_PAGE) {
60
+ throw new Error(
61
+ `${describe}: name__iexact matched ${String(page.count)} rows, more than the ` +
62
+ `${String(LOCATE_PAGE)}-row page this reads. Narrow the name rather than paging.`,
99
63
  );
64
+ }
65
+ const rows = identifies === undefined ? page.results : page.results.filter(identifies);
66
+ return soleMatch(rows, describe);
67
+ }),
68
+ );
100
69
 
101
- const objectPath = (live: PaperlessRow): string => `${spec.collection}/${String(live['id'])}`;
102
-
103
- return { fetchByName, fetchById, fetchLive, objectPath };
104
- };
70
+ /**
71
+ * ⛔ THE IDENTITY SWITCH (PR 163, red-team HIGH finding — see `matching.ts`'s header). `output`
72
+ * defined means this declaration has already written something: identity is `output.id`, full
73
+ * stop, and a changed `name`/`owner` in the caller's props is drift to PATCH, never a reason to
74
+ * relocate. `output` undefined means there is nothing to be wrong about yet — locate by name.
75
+ */
76
+ export const fetchLive = <Props, Live, Attributes extends { readonly id: number }, E, R>(
77
+ props: Props,
78
+ output: Attributes | undefined,
79
+ fetchByName: (props: Props) => Effect.Effect<Live | undefined, E, R>,
80
+ fetchById: (id: number) => Effect.Effect<Live | undefined, E, R>,
81
+ describe: (props: Props) => string,
82
+ ): Effect.Effect<Live | undefined, E, R> =>
83
+ output === undefined
84
+ ? fetchByName(props)
85
+ : fetchById(output.id).pipe(
86
+ Effect.flatMap((live) =>
87
+ live === undefined
88
+ ? Effect.die(
89
+ new Error(
90
+ `${describe(props)}: id ${String(output.id)} is recorded in state but no ` +
91
+ 'longer exists live (deleted out of band). Refusing to create a replacement ' +
92
+ '— adopt the live object back under this declaration, or remove it from ' +
93
+ 'state, before deploying again.',
94
+ ),
95
+ )
96
+ : Effect.succeed(live),
97
+ ),
98
+ );
@@ -4,21 +4,31 @@
4
4
  * itself (the identity/locate/create/patch logic the PR 163 red-team HIGH finding is about) stays
5
5
  * under the house's 250-line-per-file cap. See `matching.ts`'s header for the identity design
6
6
  * (`output.id` once a resource has state) this `MatchingSpec` shape supports.
7
+ *
8
+ * 🔴 MOVED ONTO `@distilled.cloud/paperless-ngx` (2026-09-24, decision 43) — `Live` is now
9
+ * whatever a distilled operation decodes (a `tags.Tag`, a `documentTypes.DocumentType`, …), no
10
+ * more untyped `PaperlessRow`. `E` is left to each resource file to declare (the union of the
11
+ * distilled operations it actually calls), so a call site's precise per-operation error union
12
+ * flows straight through instead of being widened by hand here — the same split
13
+ * `../netbox/resource.ts` and `../forgejo/resource.ts` use.
7
14
  */
8
- import type { PaperlessCredentialsError } from './credentials.ts';
9
- import type { PaperlessError } from './errors.ts';
10
- import type { PaperlessRequirements, PaperlessRow } from './client.ts';
11
- import type { EndpointKey } from './constraints.ts';
15
+ import type { PaperlessNgxOpContext } from '@distilled.cloud/paperless-ngx/Protocol';
16
+ import type * as Effect from 'effect/Effect';
17
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
18
+ import type { EndpointKey, PaperlessBody } from './constraints.ts';
12
19
 
13
- /** = `client.ts`'s `PaperlessRequirements` — `HttpClient` and the credentials service both. */
14
- export type MatchingRequirements = PaperlessRequirements;
15
- export type MatchingError = PaperlessError | PaperlessCredentialsError;
20
+ /**
21
+ * What a CONSUMING STACK must provide. `matchingHandlers` (matching.ts) bakes in
22
+ * `CredentialsFromEnv` itself, the same way `netboxHandlers`/`forgejoHandlers` do, so only the
23
+ * transport is left — see matching.ts's own note on `withCredentials`.
24
+ */
25
+ export type MatchingRequirements = HttpClient.HttpClient;
16
26
 
17
27
  /** A wire field beyond `name`/`owner`: how to read it from props, and how to read it back. */
18
- export interface FieldSpec<Props> {
28
+ export interface FieldSpec<Props, Live> {
19
29
  readonly key: string;
20
30
  readonly prop: (props: Props) => unknown;
21
- readonly live: (row: PaperlessRow) => unknown;
31
+ readonly live: (row: Live) => unknown;
22
32
  }
23
33
 
24
34
  export interface MatchingProps {
@@ -27,29 +37,40 @@ export interface MatchingProps {
27
37
  readonly owner?: number;
28
38
  }
29
39
 
40
+ /** The shape every `list*` distilled operation this family calls answers. */
41
+ export interface MatchingPage<Live> {
42
+ readonly count: number;
43
+ readonly results: readonly Live[];
44
+ }
45
+
30
46
  export interface MatchingSpec<
31
47
  Props extends MatchingProps,
48
+ Live extends { readonly id: number; readonly name: string },
32
49
  Attributes extends { readonly id: number },
50
+ E,
33
51
  > {
34
- /** `tags`, `document_types`, `storage_paths`, `custom_fields`. */
35
- readonly collection: string;
36
52
  /**
37
53
  * ⚠️ DEFAULT `true`. `Tag`, `DocumentType` and `StoragePath` extend Paperless-ngx's
38
54
  * `OwnedObjectSerializer`; `CustomField` does not — its wire body has no `owner` key at all
39
55
  * (measured against the v3.1.1 schema: `CustomFieldRequest` is `{name, data_type,
40
- * extra_data}`). `false` stops `createBody` sending a field the vendor would silently ignore.
56
+ * extra_data}`). `false` stops `createBody` sending a field the vendor would silently ignore,
57
+ * and `ownerOf` need not be declared.
41
58
  */
42
59
  readonly owned?: boolean;
43
60
  /** Compared, PATCHed when they differ, and sent on create. */
44
- readonly fields: readonly FieldSpec<Props>[];
61
+ readonly fields: readonly FieldSpec<Props, Live>[];
45
62
  /**
46
63
  * Sent on CREATE only — never compared, never PATCHed. `CustomField.dataType` (custom-field.ts)
47
64
  * is the one user: it must reach the create body, but `immutable` is what refuses a later
48
65
  * change, and `matches`/`patchBody` must never see it as ordinary drift.
49
66
  */
50
- readonly createOnly?: readonly FieldSpec<Props>[];
51
- readonly attributes: (live: PaperlessRow, props: Props) => Attributes | undefined;
67
+ readonly createOnly?: readonly FieldSpec<Props, Live>[];
68
+ readonly attributes: (live: Live, props: Props) => Attributes | undefined;
69
+ /** Reads the live row's owner. Required unless `owned === false` (CustomField). */
70
+ readonly ownerOf?: (live: Live) => number | undefined;
71
+ /** The generated constraint table to check each body against, by endpoint key. */
52
72
  readonly endpoint: { readonly create: EndpointKey; readonly update?: EndpointKey };
73
+ /** For `soleMatch`'s message and for read-back failures. */
53
74
  readonly describe: (props: Props) => string;
54
75
  /**
55
76
  * A field this object refuses to change once set — `CustomField.dataType` (custom-field.ts):
@@ -57,5 +78,17 @@ export interface MatchingSpec<
57
78
  * replace is acceptable. Return the refusal message, or `undefined` when unchanged. Checked in
58
79
  * `diff` (plan time) and again in `reconcile` (an adoption `diff` never saw).
59
80
  */
60
- readonly immutable?: (live: PaperlessRow, props: Props) => string | undefined;
81
+ readonly immutable?: (live: Live, props: Props) => string | undefined;
82
+
83
+ /** `name__iexact` + `page_size` — wired per resource file (`listTags`, `listDocumentTypes`, …). */
84
+ readonly list: (props: Props) => Effect.Effect<MatchingPage<Live>, E, PaperlessNgxOpContext>;
85
+ /** Already folds the SDK's `NotFound` to `undefined` — each resource file's own `.pipe(Effect.catchTag('NotFound', …))`. */
86
+ readonly getById: (id: number) => Effect.Effect<Live | undefined, E, PaperlessNgxOpContext>;
87
+ readonly create: (body: PaperlessBody) => Effect.Effect<Live, E, PaperlessNgxOpContext>;
88
+ /** Absent would mean create-only-and-never-updated; every family here declares one. */
89
+ readonly update?: (
90
+ id: number,
91
+ body: PaperlessBody,
92
+ ) => Effect.Effect<Live, E, PaperlessNgxOpContext>;
93
+ readonly destroy: (id: number) => Effect.Effect<unknown, E, PaperlessNgxOpContext>;
61
94
  }