@homeflare/alchemy 0.28.0 → 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 (210) 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 +8 -4
  40. package/dist/github/index.d.ts.map +1 -1
  41. package/dist/github/index.js +63 -23
  42. package/dist/github/index.js.map +13 -12
  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 +3 -2
  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 +3 -2
  51. package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
  52. package/dist/github/repository-ruleset-octokit.d.ts +49 -0
  53. package/dist/github/repository-ruleset-octokit.d.ts.map +1 -1
  54. package/dist/github/repository-ruleset-probe.d.ts +2 -1
  55. package/dist/github/repository-ruleset-probe.d.ts.map +1 -1
  56. package/dist/github/repository-ruleset-props.d.ts +7 -4
  57. package/dist/github/repository-ruleset-props.d.ts.map +1 -1
  58. package/dist/github/repository-ruleset-providers.d.ts +42 -0
  59. package/dist/github/repository-ruleset-providers.d.ts.map +1 -0
  60. package/dist/github/repository-ruleset.d.ts +26 -4
  61. package/dist/github/repository-ruleset.d.ts.map +1 -1
  62. package/dist/litellm/fake-litellm.d.ts +27 -16
  63. package/dist/litellm/fake-litellm.d.ts.map +1 -1
  64. package/dist/litellm/fake-stack.d.ts +9 -4
  65. package/dist/litellm/fake-stack.d.ts.map +1 -1
  66. package/dist/litellm/index.d.ts +7 -5
  67. package/dist/litellm/index.d.ts.map +1 -1
  68. package/dist/litellm/index.js +53 -123
  69. package/dist/litellm/index.js.map +8 -8
  70. package/dist/litellm/operations.d.ts +60 -0
  71. package/dist/litellm/operations.d.ts.map +1 -0
  72. package/dist/litellm/pass-through-endpoint.d.ts +10 -28
  73. package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
  74. package/dist/litellm/pass-through-errors.d.ts +37 -0
  75. package/dist/litellm/pass-through-errors.d.ts.map +1 -0
  76. package/dist/litellm/pass-through-form.d.ts +11 -4
  77. package/dist/litellm/pass-through-form.d.ts.map +1 -1
  78. package/dist/litellm/providers.d.ts +7 -5
  79. package/dist/litellm/providers.d.ts.map +1 -1
  80. package/dist/netbox/prefix.d.ts +4 -4
  81. package/dist/paperless/custom-field.d.ts +21 -21
  82. package/dist/paperless/custom-field.d.ts.map +1 -1
  83. package/dist/paperless/document-type.d.ts +34 -4
  84. package/dist/paperless/document-type.d.ts.map +1 -1
  85. package/dist/paperless/fake-paperless.d.ts +36 -19
  86. package/dist/paperless/fake-paperless.d.ts.map +1 -1
  87. package/dist/paperless/index.d.ts +7 -2
  88. package/dist/paperless/index.d.ts.map +1 -1
  89. package/dist/paperless/index.js +164 -228
  90. package/dist/paperless/index.js.map +10 -13
  91. package/dist/paperless/matching-locate.d.ts +42 -20
  92. package/dist/paperless/matching-locate.d.ts.map +1 -1
  93. package/dist/paperless/matching-types.d.ts +47 -18
  94. package/dist/paperless/matching-types.d.ts.map +1 -1
  95. package/dist/paperless/matching.d.ts +30 -27
  96. package/dist/paperless/matching.d.ts.map +1 -1
  97. package/dist/paperless/providers.d.ts +13 -7
  98. package/dist/paperless/providers.d.ts.map +1 -1
  99. package/dist/paperless/storage-path.d.ts +34 -4
  100. package/dist/paperless/storage-path.d.ts.map +1 -1
  101. package/dist/paperless/tag.d.ts +43 -8
  102. package/dist/paperless/tag.d.ts.map +1 -1
  103. package/dist/proxmox/acl.d.ts +30 -23
  104. package/dist/proxmox/acl.d.ts.map +1 -1
  105. package/dist/proxmox/distilled-pve.d.ts +89 -0
  106. package/dist/proxmox/distilled-pve.d.ts.map +1 -0
  107. package/dist/proxmox/index.js +663 -609
  108. package/dist/proxmox/index.js.map +12 -11
  109. package/dist/proxmox/members.d.ts +29 -10
  110. package/dist/proxmox/members.d.ts.map +1 -1
  111. package/docs/caddy-admin.md +1 -1
  112. package/docs/caddy.md +3 -2
  113. package/docs/credentials.md +16 -0
  114. package/docs/litellm.md +21 -13
  115. package/docs/paperless.md +12 -0
  116. package/docs/peers.md +5 -0
  117. package/docs/repo-policy.md +16 -1
  118. package/docs/repository-ruleset.md +53 -13
  119. package/docs/upstream-conformance.md +126 -10
  120. package/package.json +5 -2
  121. package/src/caddy/admin-address.ts +71 -0
  122. package/src/caddy/admin-calls.ts +48 -100
  123. package/src/caddy/admin.ts +37 -41
  124. package/src/caddy/adopt-scope.test.ts +1 -1
  125. package/src/caddy/caddy-http-client.ts +229 -0
  126. package/src/caddy/config-adopt-provider.test.ts +139 -0
  127. package/src/caddy/config-adopt.test.ts +60 -126
  128. package/src/caddy/config-errors.ts +47 -0
  129. package/src/caddy/config-lifecycle.test.ts +55 -42
  130. package/src/caddy/config-lifecycle.ts +72 -162
  131. package/src/caddy/config-reconcile.ts +138 -0
  132. package/src/caddy/config.ts +36 -42
  133. package/src/caddy/fake-caddy.ts +20 -2
  134. package/src/caddy/index.ts +3 -8
  135. package/src/caddy/local-admin.test.ts +35 -21
  136. package/src/caddy/local-admin.ts +60 -167
  137. package/src/caddy/providers.test.ts +14 -6
  138. package/src/caddy/providers.ts +20 -4
  139. package/src/github/declare-repo-baseline.ts +6 -6
  140. package/src/github/index.ts +8 -4
  141. package/src/github/repo-baseline-data.test.ts +13 -0
  142. package/src/github/repo-baseline-data.ts +11 -2
  143. package/src/github/repo-policy-stack.test.ts +59 -0
  144. package/src/github/repo-policy.ts +3 -2
  145. package/src/github/repository-ruleset-errors.ts +6 -4
  146. package/src/github/repository-ruleset-exact-adopt-k1.test.ts +109 -0
  147. package/src/github/repository-ruleset-exact-adopt.test.ts +14 -9
  148. package/src/github/repository-ruleset-form.ts +7 -3
  149. package/src/github/repository-ruleset-guards.ts +3 -2
  150. package/src/github/repository-ruleset-octokit.test.ts +141 -0
  151. package/src/github/repository-ruleset-octokit.ts +121 -30
  152. package/src/github/repository-ruleset-probe.ts +2 -1
  153. package/src/github/repository-ruleset-props.ts +7 -4
  154. package/src/github/repository-ruleset-providers.ts +46 -0
  155. package/src/github/repository-ruleset-wire.test.ts +38 -27
  156. package/src/github/repository-ruleset.ts +27 -3
  157. package/src/litellm/fake-litellm.ts +83 -78
  158. package/src/litellm/fake-stack.ts +13 -5
  159. package/src/litellm/index.ts +7 -21
  160. package/src/litellm/operations.ts +149 -0
  161. package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +17 -20
  162. package/src/litellm/pass-through-endpoint-replace.test.ts +3 -4
  163. package/src/litellm/pass-through-endpoint.test.ts +8 -8
  164. package/src/litellm/pass-through-endpoint.ts +21 -31
  165. package/src/litellm/pass-through-errors.ts +37 -0
  166. package/src/litellm/pass-through-form.ts +20 -10
  167. package/src/litellm/providers.ts +8 -7
  168. package/src/paperless/custom-field.test.ts +49 -40
  169. package/src/paperless/custom-field.ts +57 -33
  170. package/src/paperless/document-type.test.ts +48 -0
  171. package/src/paperless/document-type.ts +51 -22
  172. package/src/paperless/fake-paperless.ts +63 -72
  173. package/src/paperless/index.ts +7 -13
  174. package/src/paperless/matching-locate.test.ts +102 -0
  175. package/src/paperless/matching-locate.ts +80 -86
  176. package/src/paperless/matching-types.ts +49 -16
  177. package/src/paperless/matching.ts +77 -90
  178. package/src/paperless/providers.ts +14 -10
  179. package/src/paperless/storage-path.test.ts +56 -0
  180. package/src/paperless/storage-path.ts +53 -24
  181. package/src/paperless/tag-identity.test.ts +127 -0
  182. package/src/paperless/tag.test.ts +100 -0
  183. package/src/paperless/tag.ts +62 -32
  184. package/src/proxmox/acl.test.ts +145 -0
  185. package/src/proxmox/acl.ts +128 -150
  186. package/src/proxmox/distilled-pve.test.ts +231 -0
  187. package/src/proxmox/distilled-pve.ts +147 -0
  188. package/src/proxmox/members-timeout.test.ts +161 -0
  189. package/src/proxmox/members.test.ts +9 -0
  190. package/src/proxmox/members.ts +36 -2
  191. package/dist/litellm/client.d.ts +0 -76
  192. package/dist/litellm/client.d.ts.map +0 -1
  193. package/dist/litellm/credentials.d.ts +0 -48
  194. package/dist/litellm/credentials.d.ts.map +0 -1
  195. package/dist/litellm/generated/pass-through.d.ts +0 -71
  196. package/dist/litellm/generated/pass-through.d.ts.map +0 -1
  197. package/dist/paperless/client.d.ts +0 -37
  198. package/dist/paperless/client.d.ts.map +0 -1
  199. package/dist/paperless/credentials.d.ts +0 -40
  200. package/dist/paperless/credentials.d.ts.map +0 -1
  201. package/dist/paperless/errors.d.ts +0 -49
  202. package/dist/paperless/errors.d.ts.map +0 -1
  203. package/src/litellm/client.ts +0 -195
  204. package/src/litellm/credentials.ts +0 -73
  205. package/src/litellm/generated/pass-through.ts +0 -73
  206. package/src/paperless/client.ts +0 -88
  207. package/src/paperless/credentials.ts +0 -69
  208. package/src/paperless/errors.ts +0 -62
  209. package/src/paperless/matching-identity.test.ts +0 -194
  210. package/src/paperless/matching.test.ts +0 -128
@@ -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
  }