@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
@@ -1,24 +1,27 @@
1
1
  /**
2
2
  * `Paperless.CustomField.dataType` is create-only: a change is refused at plan time, never
3
3
  * silently PATCHed and never routed to a replace. See custom-field.ts's header for why — a
4
- * replace would delete the field's value on every document.
5
- *
6
- * ⛔ THESE TESTS FAIL WITHOUT THE UNIT. `custom-field.ts` did not exist before this change.
4
+ * replace would delete the field's value on every document. The live-lifecycle tests below drive
5
+ * the real `@distilled.cloud/paperless-ngx` wire path (tag.test.ts's own note on why
6
+ * `matchingOperations(spec)`, not `customFieldHandlers`, is what a test can point at a fake).
7
7
  */
8
8
  import { describe, expect, test } from 'bun:test';
9
- import { customFieldHandlers, dataTypeImmutable } from './custom-field.ts';
10
- import { run, withFake } from './fake-paperless.ts';
9
+ import { dataTypeImmutable, spec } from './custom-field.ts';
10
+ import { fakePaperless, run } from './fake-paperless.ts';
11
+ import { matchingOperations } from './matching.ts';
12
+
13
+ const ops = matchingOperations(spec);
11
14
 
12
15
  describe('dataTypeImmutable, the pure rule', () => {
13
16
  test('no refusal when the live value is unchanged', () => {
14
17
  expect(
15
- dataTypeImmutable({ data_type: 'string', id: 1 }, { dataType: 'string', name: 'x' }),
18
+ dataTypeImmutable({ data_type: 'string' }, { dataType: 'string', name: 'x' }),
16
19
  ).toBeUndefined();
17
20
  });
18
21
 
19
22
  test('a refusal when the live value differs, naming both values', () => {
20
23
  const message = dataTypeImmutable(
21
- { data_type: 'select', id: 1 },
24
+ { data_type: 'select' },
22
25
  { dataType: 'string', name: 'Status' },
23
26
  );
24
27
  expect(message).toMatch(/data_type is create-only/);
@@ -29,46 +32,52 @@ describe('dataTypeImmutable, the pure rule', () => {
29
32
 
30
33
  describe('the live lifecycle refuses a data_type change', () => {
31
34
  test('reconcile dies rather than PATCHing a changed data_type', async () => {
32
- await withFake(async (fake) => {
33
- await run(customFieldHandlers.reconcile({ news: { dataType: 'select', name: 'Status' } }));
34
- const before = fake.seen.filter((s) => s.method !== 'GET').length;
35
+ const fake = fakePaperless();
36
+ const created = await run(
37
+ ops.reconcile({ dataType: 'select', name: 'Status' }, undefined),
38
+ fake.fetch,
39
+ );
40
+ const before = fake.seen.filter((s) => s.method !== 'GET').length;
35
41
 
36
- await expect(
37
- run(customFieldHandlers.reconcile({ news: { dataType: 'string', name: 'Status' } })),
38
- ).rejects.toThrow(/data_type is create-only/);
42
+ await expect(
43
+ run(ops.reconcile({ dataType: 'string', name: 'Status' }, created), fake.fetch),
44
+ ).rejects.toThrow(/data_type is create-only/);
39
45
 
40
- // ⛔ NOTHING WAS WRITTEN — the refusal fires before the PATCH, not after a failed one.
41
- expect(fake.seen.filter((s) => s.method !== 'GET').length).toBe(before);
42
- });
46
+ // ⛔ NOTHING WAS WRITTEN — the refusal fires before the PATCH, not after a failed one.
47
+ expect(fake.seen.filter((s) => s.method !== 'GET').length).toBe(before);
43
48
  });
44
49
 
45
50
  test('a create followed by a declaration of the SAME data_type is a normal no-drift reconcile', async () => {
46
- await withFake(async () => {
47
- await run(customFieldHandlers.reconcile({ news: { dataType: 'select', name: 'Status' } }));
48
- const after = await run(
49
- customFieldHandlers.reconcile({ news: { dataType: 'select', name: 'Status' } }),
50
- );
51
- expect(after).toMatchObject({ dataType: 'select', name: 'Status' });
52
- });
51
+ const fake = fakePaperless();
52
+ const created = await run(
53
+ ops.reconcile({ dataType: 'select', name: 'Status' }, undefined),
54
+ fake.fetch,
55
+ );
56
+ const after = await run(
57
+ ops.reconcile({ dataType: 'select', name: 'Status' }, created),
58
+ fake.fetch,
59
+ );
60
+ expect(after).toMatchObject({ dataType: 'select', name: 'Status' });
53
61
  });
54
62
 
55
63
  test('extraData still updates through a normal PATCH', async () => {
56
- await withFake(async (fake) => {
57
- await run(
58
- customFieldHandlers.reconcile({
59
- news: { dataType: 'select', extraData: { select_options: ['a'] }, name: 'Status' },
60
- }),
61
- );
62
- await run(
63
- customFieldHandlers.reconcile({
64
- news: { dataType: 'select', extraData: { select_options: ['a', 'b'] }, name: 'Status' },
65
- }),
66
- );
67
- const patch = fake.seen.find((s) => s.method === 'PATCH');
68
- expect(patch).toBeDefined();
69
- expect(JSON.parse(patch?.body ?? '{}')).toEqual({
70
- extra_data: { select_options: ['a', 'b'] },
71
- });
72
- });
64
+ const fake = fakePaperless();
65
+ const created = await run(
66
+ ops.reconcile(
67
+ { dataType: 'select', extraData: { select_options: ['a'] }, name: 'Status' },
68
+ undefined,
69
+ ),
70
+ fake.fetch,
71
+ );
72
+ await run(
73
+ ops.reconcile(
74
+ { dataType: 'select', extraData: { select_options: ['a', 'b'] }, name: 'Status' },
75
+ created,
76
+ ),
77
+ fake.fetch,
78
+ );
79
+ const patch = fake.seen.find((s) => s.method === 'PATCH');
80
+ expect(patch).toBeDefined();
81
+ expect(JSON.parse(patch?.body ?? '{}')).toEqual({ extra_data: { select_options: ['a', 'b'] } });
73
82
  });
74
83
  });
@@ -7,28 +7,32 @@
7
7
  * this refusal generically; `dataType` is simply never listed in `fields`, so nothing in
8
8
  * `matching.ts` ever builds a PATCH body that includes it.
9
9
  *
10
- * ⛔ NO `owner` FIELD. Unlike `Tag`/`DocumentType`/`StoragePath`, `CustomField` does not extend
11
- * Paperless-ngx's `OwnedObjectSerializer` — its wire body is exactly `{name, data_type,
12
- * extra_data}` (measured against the v3.1.1 schema). `owned: false` stops `matching.ts`
13
- * sending a field the vendor's serializer does not declare.
10
+ * ⛔ NO `owner` FIELD, AND NO `ownerOf` SPEC ENTRY. Unlike `Tag`/`DocumentType`/`StoragePath`,
11
+ * `CustomField` does not extend Paperless-ngx's `OwnedObjectSerializer` — its wire body is
12
+ * exactly `{name, data_type, extra_data}` (measured against the v3.1.1 schema, and confirmed
13
+ * again in `@distilled.cloud/paperless-ngx`'s generated `custom_fields.ts`, which has no
14
+ * `owner` field either). `owned: false` stops `matching.ts` sending or comparing one.
14
15
  *
15
16
  * ⚠️ `CustomField.select_options` (the vendor's own rule for a `select` field's option list) is
16
- * not in the served schema at all, so it is recorded here and NOT enforced (groundTruth).
17
+ * not in the served schema at all, so it is recorded here and NOT enforced (groundTruth). See
18
+ * `tag.ts`'s header for the 2026-09-24 transport migration this file follows.
17
19
  */
18
20
  import { Resource } from 'alchemy';
19
21
  import * as Provider from 'alchemy/Provider';
22
+ import * as customFields from '@distilled.cloud/paperless-ngx/custom_fields';
20
23
  import * as Effect from 'effect/Effect';
21
24
  import type { PaperlessCustomFieldRequest } from './generated/types/custom-fields.ts';
22
- import type { PaperlessRow } from './client.ts';
23
- import { type MatchingRequirements, matchingHandlers } from './matching.ts';
25
+ import {
26
+ LOCATE_PAGE,
27
+ type MatchingRequirements,
28
+ type MatchingSpec,
29
+ matchingHandlers,
30
+ } from './matching.ts';
24
31
 
25
32
  export interface CustomFieldProps {
26
- /** ⚠️ maxLength 128 (generated constraint table). The locate key — CustomField's name is globally unique (models.py:1169), with no owner to further scope it. */
33
+ /** ⚠️ maxLength 128 (generated constraint table). CustomField's name is globally unique (models.py:1169), with no owner to further scope it. */
27
34
  name: string;
28
- /**
29
- * ⛔ CREATE-ONLY — see this file's header. `select`'s own option list
30
- * (`select_options`) is not in the vendor schema and is unenforced here.
31
- */
35
+ /** ⛔ CREATE-ONLY — see this file's header. `select`'s own option list (`select_options`) is not in the vendor schema and is unenforced here. */
32
36
  dataType: PaperlessCustomFieldRequest['data_type'];
33
37
  /** Arbitrary JSON the vendor attaches no schema to (e.g. a `select` field's option list). */
34
38
  extraData?: unknown;
@@ -54,42 +58,62 @@ export const CustomField = Resource<CustomField>('Paperless.CustomField', {
54
58
  });
55
59
 
56
60
  /**
57
- * ⛔ EXTRACTED SO IT CAN BE TESTED WITHOUT A SERVER (custom-field.test.ts) — the same reason
58
- * `../netbox/prefix-form.ts` pulls its body/matches functions out of `prefix.ts`.
61
+ * ⛔ EXTRACTED SO IT CAN BE TESTED WITHOUT A SERVER (custom-field.test.ts). `Pick<…, 'data_type'>`
62
+ * rather than the full `CustomField`, so a test fixture need not spell out `name`/`document_count`.
59
63
  */
60
64
  export const dataTypeImmutable = (
61
- live: PaperlessRow,
65
+ live: Pick<customFields.CustomField, 'data_type'>,
62
66
  props: CustomFieldProps,
63
67
  ): string | undefined =>
64
- typeof live['data_type'] === 'string' && live['data_type'] !== props.dataType
65
- ? `Paperless.CustomField "${props.name}": data_type is create-only (live: ${live['data_type']}, ` +
68
+ live.data_type !== props.dataType
69
+ ? `Paperless.CustomField "${props.name}": data_type is create-only (live: ${live.data_type}, ` +
66
70
  `declared: ${props.dataType}). Changing it would delete this field's value on every ` +
67
71
  'document. Create a new custom field instead, and retire this one by hand.'
68
72
  : undefined;
69
73
 
70
- /** ⚠️ Exported (not through index.ts) so custom-field.test.ts can drive the real lifecycle. */
71
- export const customFieldHandlers = matchingHandlers<CustomFieldProps, CustomFieldAttributes>({
72
- attributes: (live, props) => {
73
- const id = live['id'];
74
- if (typeof id !== 'number') return undefined;
75
- return {
76
- dataType: typeof live['data_type'] === 'string' ? live['data_type'] : props.dataType,
77
- extraData: live['extra_data'],
78
- id,
79
- name: typeof live['name'] === 'string' ? live['name'] : props.name,
80
- };
81
- },
82
- collection: 'custom_fields',
74
+ /** ★ Exported for direct testing — see tag.ts's own note on this seam. */
75
+ export const spec: MatchingSpec<
76
+ CustomFieldProps,
77
+ customFields.CustomField,
78
+ CustomFieldAttributes,
79
+ | customFields.CreateCustomFieldError
80
+ | customFields.GetCustomFieldError
81
+ | customFields.ListCustomFieldsError
82
+ | customFields.UpdateCustomFieldsPartialError
83
+ | customFields.CustomFieldsDestroyError
84
+ > = {
85
+ attributes: (live, props) => ({
86
+ dataType: live.data_type ?? props.dataType,
87
+ extraData: live.extra_data,
88
+ id: live.id,
89
+ name: live.name,
90
+ }),
91
+ create: (body) =>
92
+ customFields.createCustomField(body as unknown as customFields.CreateCustomFieldRequest),
93
+ createOnly: [{ key: 'data_type', live: (r) => r.data_type, prop: (p) => p.dataType }],
83
94
  describe: (props) => `custom_fields ${props.name}`,
95
+ destroy: (id) => customFields.customFieldsDestroy({ id }),
84
96
  endpoint: {
85
97
  create: 'paperless:POST /api/custom_fields/',
86
98
  update: 'paperless:PATCH /api/custom_fields/{id}/',
87
99
  },
88
- createOnly: [{ key: 'data_type', live: (r) => r['data_type'], prop: (p) => p.dataType }],
89
- fields: [{ key: 'extra_data', live: (r) => r['extra_data'], prop: (p) => p.extraData }],
100
+ fields: [{ key: 'extra_data', live: (r) => r.extra_data, prop: (p) => p.extraData }],
101
+ getById: (id) =>
102
+ customFields
103
+ .getCustomField({ id })
104
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
90
105
  immutable: dataTypeImmutable,
106
+ list: (props) =>
107
+ customFields.listCustomFields({ name__iexact: props.name, page_size: LOCATE_PAGE }),
91
108
  owned: false,
92
- });
109
+ update: (id, body) =>
110
+ customFields.updateCustomFieldsPartial({
111
+ ...(body as unknown as Omit<customFields.UpdateCustomFieldsPartialRequest, 'id'>),
112
+ id,
113
+ }),
114
+ };
115
+
116
+ export const customFieldHandlers = matchingHandlers(spec);
93
117
 
94
118
  export const CustomFieldProvider = () =>
95
119
  Provider.effect(CustomField, Effect.succeed(CustomField.Provider.of(customFieldHandlers)));
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `DocumentType`'s exported production `spec` against a fake Paperless-ngx — proves this file's
3
+ * own `@distilled.cloud/paperless-ngx/document_types` wiring (right operations, right field
4
+ * names) round-trips for real. The shared `matching.ts` engine and the PR 163 identity switch are
5
+ * proven generically by `tag.test.ts`/`tag-identity.test.ts`/`matching-locate.test.ts`; this file
6
+ * does not repeat that coverage.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { spec } from './document-type.ts';
10
+ import { fakePaperless, run } from './fake-paperless.ts';
11
+ import { matchingOperations } from './matching.ts';
12
+
13
+ const ops = matchingOperations(spec);
14
+
15
+ test('create sends owner: null when undeclared, and the constraint-checked body', async () => {
16
+ const fake = fakePaperless();
17
+ await run(ops.reconcile({ name: 'Invoices' }, undefined), fake.fetch);
18
+ const post = fake.seen.find((s) => s.method === 'POST');
19
+ expect(post).toBeDefined();
20
+ expect(JSON.parse(post?.body ?? '{}')).toMatchObject({ name: 'Invoices', owner: null });
21
+ });
22
+
23
+ describe('a full create → read → patch → delete round trip', () => {
24
+ test('the real distilled wire path carries every field through', async () => {
25
+ const fake = fakePaperless();
26
+ const created = await run(
27
+ ops.reconcile({ isInsensitive: true, match: 'invoice', name: 'Invoices' }, undefined),
28
+ fake.fetch,
29
+ );
30
+ expect(created).toMatchObject({ isInsensitive: true, match: 'invoice', name: 'Invoices' });
31
+
32
+ const after = await run(
33
+ ops.reconcile({ isInsensitive: true, match: 'receipt', name: 'Invoices' }, created),
34
+ fake.fetch,
35
+ );
36
+ const patch = fake.seen.find((s) => s.method === 'PATCH');
37
+ expect(JSON.parse(patch?.body ?? '{}')).toEqual({ match: 'receipt' });
38
+ expect(after?.match).toBe('receipt');
39
+
40
+ await run(ops.destroy({ name: 'Invoices' }, after), fake.fetch);
41
+ expect(fake.rows('document_types').length).toBe(0);
42
+ });
43
+ });
44
+
45
+ test('a 404 on read folds to absent', async () => {
46
+ const fake = fakePaperless();
47
+ expect(await run(ops.read({ name: 'Nothing here' }), fake.fetch)).toBeUndefined();
48
+ });
@@ -1,17 +1,25 @@
1
1
  /**
2
2
  * `Paperless.DocumentType` — a Paperless-ngx document type, create-or-update and never deleted.
3
3
  * Same shape as `tag.ts` minus `color`/`isInboxTag`/`parent` — see that file's header for why
4
- * `matchingAlgorithm`'s type comes off the generated request type rather than being retyped.
4
+ * `matchingAlgorithm`'s type comes off the generated request type rather than being retyped, and
5
+ * for the 2026-09-24 transport migration this file follows exactly.
5
6
  */
6
7
  import { Resource } from 'alchemy';
7
8
  import * as Provider from 'alchemy/Provider';
9
+ import * as documentTypes from '@distilled.cloud/paperless-ngx/document_types';
8
10
  import * as Effect from 'effect/Effect';
9
11
  import type { PaperlessDocumentTypeRequest } from './generated/types/document-types.ts';
10
- import { type MatchingProps, type MatchingRequirements, matchingHandlers } from './matching.ts';
12
+ import {
13
+ LOCATE_PAGE,
14
+ type MatchingRequirements,
15
+ type MatchingSpec,
16
+ matchingHandlers,
17
+ } from './matching.ts';
11
18
 
12
- export interface DocumentTypeProps extends MatchingProps {
19
+ export interface DocumentTypeProps {
13
20
  /** ⚠️ maxLength 128 (generated constraint table). The locate key. */
14
21
  name: string;
22
+ owner?: number;
15
23
  /** ⚠️ maxLength 256. */
16
24
  match?: string;
17
25
  matchingAlgorithm?: PaperlessDocumentTypeRequest['matching_algorithm'];
@@ -39,36 +47,57 @@ export const DocumentType = Resource<DocumentType>('Paperless.DocumentType', {
39
47
  defaultRemovalPolicy: 'retain',
40
48
  });
41
49
 
42
- const handlers = matchingHandlers<DocumentTypeProps, DocumentTypeAttributes>({
43
- attributes: (live, props) => {
44
- const id = live['id'];
45
- if (typeof id !== 'number') return undefined;
46
- return {
47
- id,
48
- isInsensitive: live['is_insensitive'] === true,
49
- match: typeof live['match'] === 'string' ? live['match'] : '',
50
- matchingAlgorithm:
51
- typeof live['matching_algorithm'] === 'number' ? live['matching_algorithm'] : 0,
52
- name: typeof live['name'] === 'string' ? live['name'] : props.name,
53
- owner: typeof live['owner'] === 'number' ? live['owner'] : undefined,
54
- };
55
- },
56
- collection: 'document_types',
50
+ /** ★ Exported for direct testing — see tag.ts's own note on this seam. */
51
+ export const spec: MatchingSpec<
52
+ DocumentTypeProps,
53
+ documentTypes.DocumentType,
54
+ DocumentTypeAttributes,
55
+ | documentTypes.CreateDocumentTypeError
56
+ | documentTypes.GetDocumentTypeError
57
+ | documentTypes.ListDocumentTypesError
58
+ | documentTypes.UpdateDocumentTypesPartialError
59
+ | documentTypes.DocumentTypesDestroyError
60
+ > = {
61
+ attributes: (live, props) => ({
62
+ id: live.id,
63
+ isInsensitive: live.is_insensitive === true,
64
+ match: live.match ?? '',
65
+ matchingAlgorithm: live.matching_algorithm ?? 0,
66
+ name: live.name,
67
+ owner: live.owner ?? undefined,
68
+ }),
69
+ create: (body) =>
70
+ documentTypes.createDocumentType(body as unknown as documentTypes.CreateDocumentTypeRequest),
57
71
  describe: (props) => `document_types ${props.name}`,
72
+ destroy: (id) => documentTypes.documentTypesDestroy({ id }),
58
73
  endpoint: {
59
74
  create: 'paperless:POST /api/document_types/',
60
75
  update: 'paperless:PATCH /api/document_types/{id}/',
61
76
  },
62
77
  fields: [
63
- { key: 'match', live: (r) => r['match'], prop: (p) => p.match },
78
+ { key: 'match', live: (r) => r.match, prop: (p) => p.match },
64
79
  {
65
80
  key: 'matching_algorithm',
66
- live: (r) => r['matching_algorithm'],
81
+ live: (r) => r.matching_algorithm,
67
82
  prop: (p) => p.matchingAlgorithm,
68
83
  },
69
- { key: 'is_insensitive', live: (r) => r['is_insensitive'], prop: (p) => p.isInsensitive },
84
+ { key: 'is_insensitive', live: (r) => r.is_insensitive, prop: (p) => p.isInsensitive },
70
85
  ],
71
- });
86
+ getById: (id) =>
87
+ documentTypes
88
+ .getDocumentType({ id })
89
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
90
+ list: (props) =>
91
+ documentTypes.listDocumentTypes({ name__iexact: props.name, page_size: LOCATE_PAGE }),
92
+ ownerOf: (live) => live.owner ?? undefined,
93
+ update: (id, body) =>
94
+ documentTypes.updateDocumentTypesPartial({
95
+ ...(body as unknown as Omit<documentTypes.UpdateDocumentTypesPartialRequest, 'id'>),
96
+ id,
97
+ }),
98
+ };
99
+
100
+ export const handlers = matchingHandlers(spec);
72
101
 
73
102
  export const DocumentTypeProvider = () =>
74
103
  Provider.effect(DocumentType, Effect.succeed(DocumentType.Provider.of(handlers)));
@@ -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,