@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,100 @@
1
+ /**
2
+ * `Tag`'s exported production `spec`, driven through `matchingOperations` against a fake
3
+ * Paperless-ngx that speaks the real distilled wire protocol — the successor to the pre-migration
4
+ * `matching.test.ts`, now proving `@distilled.cloud/paperless-ngx`'s real path assembly, JSON
5
+ * encode/decode and status→typed-error matching (`404` → `NotFound`, `403` → `Forbidden`, …)
6
+ * instead of a hand-rolled minimal spec against a status-carrying `PaperlessError`.
7
+ * `matching-locate.test.ts` covers the pure identity rule; `tag-identity.test.ts` covers the
8
+ * PR 163 rename/re-own/delete-out-of-band regression suite this file does not repeat.
9
+ *
10
+ * ⚠️ `handlers` (tag.ts's own export, built by `matchingHandlers`) IS NOT USED HERE — it bakes in
11
+ * `CredentialsFromEnv`, which resolves through Effect `Config` and cannot be redirected to a
12
+ * fake once built (fake-paperless.ts's own header). Every test calls `matchingOperations(spec)`
13
+ * instead, with an explicit fake `Credentials` layer — the same seam
14
+ * `../netbox/prefix.test.ts`/`../forgejo/repository.test.ts` use.
15
+ */
16
+ import { describe, expect, test } from 'bun:test';
17
+ import { fakePaperless, run, runFailure } from './fake-paperless.ts';
18
+ import { matchingOperations } from './matching.ts';
19
+ import { spec } from './tag.ts';
20
+
21
+ const ops = matchingOperations(spec);
22
+
23
+ describe('create always carries owner, null when undeclared', () => {
24
+ test('a create with no owner prop sends owner: null', async () => {
25
+ const fake = fakePaperless();
26
+ await run(ops.reconcile({ name: 'Invoices' }, undefined), fake.fetch);
27
+ const post = fake.seen.find((s) => s.method === 'POST');
28
+ expect(post).toBeDefined();
29
+ expect(JSON.parse(post?.body ?? '{}')).toMatchObject({ name: 'Invoices', owner: null });
30
+ });
31
+
32
+ test('a create with an owner prop sends that owner', async () => {
33
+ const fake = fakePaperless();
34
+ await run(ops.reconcile({ name: 'Receipts', owner: 7 }, undefined), fake.fetch);
35
+ const post = fake.seen.find((s) => s.method === 'POST');
36
+ expect(JSON.parse(post?.body ?? '{}')).toMatchObject({ owner: 7 });
37
+ });
38
+ });
39
+
40
+ test('a no-drift reconcile makes zero writes', async () => {
41
+ const fake = fakePaperless();
42
+ await run(ops.reconcile({ name: 'Invoices' }, undefined), fake.fetch);
43
+ const before = fake.seen.filter((s) => s.method !== 'GET').length;
44
+ await run(ops.reconcile({ name: 'Invoices' }, undefined), fake.fetch);
45
+ expect(fake.seen.filter((s) => s.method !== 'GET').length).toBe(before);
46
+ });
47
+
48
+ test('a 403 fails as Forbidden rather than reading as absent', async () => {
49
+ const fake = fakePaperless();
50
+ fake.forceStatus = 403;
51
+ const error = await runFailure(ops.read({ name: 'Invoices' }), fake.fetch);
52
+ expect((error as { _tag?: string })._tag).toBe('Forbidden');
53
+ });
54
+
55
+ test('a 404 on read folds to absent (create is the right plan)', async () => {
56
+ const fake = fakePaperless();
57
+ expect(await run(ops.read({ name: 'Nothing here' }), fake.fetch)).toBeUndefined();
58
+ });
59
+
60
+ describe('case-insensitive locate, case-sensitive identity', () => {
61
+ test('two differently-cased names never collapse into one object', async () => {
62
+ const fake = fakePaperless();
63
+ await run(ops.reconcile({ name: 'Invoices' }, undefined), fake.fetch);
64
+ await run(ops.reconcile({ name: 'invoices' }, undefined), fake.fetch);
65
+ expect(fake.rows('tags').length).toBe(2);
66
+
67
+ const exact = await run(ops.read({ name: 'Invoices' }), fake.fetch);
68
+ expect(exact?.name).toBe('Invoices');
69
+ const other = await run(ops.read({ name: 'invoices' }), fake.fetch);
70
+ expect(other?.name).toBe('invoices');
71
+ });
72
+ });
73
+
74
+ test('a PATCH sends only the fields that differ from the live row', async () => {
75
+ const fake = fakePaperless();
76
+ await run(ops.reconcile({ color: '#a6cee3', name: 'Invoices' }, undefined), fake.fetch);
77
+ const created = await run(ops.read({ name: 'Invoices' }), fake.fetch);
78
+
79
+ const after = await run(
80
+ ops.reconcile({ color: '#a6cee3', match: 'invoice', name: 'Invoices' }, created),
81
+ fake.fetch,
82
+ );
83
+
84
+ const patch = fake.seen.find((s) => s.method === 'PATCH');
85
+ expect(patch).toBeDefined();
86
+ expect(JSON.parse(patch?.body ?? '{}')).toEqual({ match: 'invoice' });
87
+ expect(after?.match).toBe('invoice');
88
+ });
89
+
90
+ test('destroy DELETEs the live row and is idempotent when already gone', async () => {
91
+ const fake = fakePaperless();
92
+ await run(ops.reconcile({ name: 'Invoices' }, undefined), fake.fetch);
93
+ const created = await run(ops.read({ name: 'Invoices' }), fake.fetch);
94
+
95
+ await run(ops.destroy({ name: 'Invoices' }, created), fake.fetch);
96
+ expect(fake.rows('tags').length).toBe(0);
97
+
98
+ // ⚠️ A second destroy of the same (now-absent) row must not throw.
99
+ await run(ops.destroy({ name: 'Invoices' }, created), fake.fetch);
100
+ });
@@ -2,24 +2,32 @@
2
2
  * `Paperless.Tag` — a Paperless-ngx tag, create-or-update and never deleted by default.
3
3
  *
4
4
  * ★ `matchingAlgorithm` AND THE OTHER ENUM-SHAPED FIELDS ARE INDEXED OFF THE GENERATED REQUEST
5
- * TYPE (`PaperlessTagRequest['matching_algorithm']`), NEVER RETYPED. The literal union `0 | 1 |
6
- * … | 6` comes from `codegen/paperless.ts`'s reading of the vendor's own `MatchingAlgorithm`
7
- * enum — copying it by hand here would be exactly the guess `schema-provenance` doctrine
8
- * forbids, and a vendor that adds a seventh algorithm would leave this type silently stale.
5
+ * TYPE (`PaperlessTagRequest['matching_algorithm']`), NEVER RETYPED — see this family's header
6
+ * in `docs/paperless.md`. Unchanged by the 2026-09-24 transport migration below: that generated
7
+ * table is `codegen/paperless.ts`'s own concern, not `@distilled.cloud/paperless-ngx`'s.
9
8
  *
10
- * ⛔ NO CREDENTIAL IS A PROP. `PAPERLESS_URL`/`PAPERLESS_TOKEN` come from `credentials.ts`.
9
+ * ⛔ NO CREDENTIAL IS A PROP. `PAPERLESS_URL`/`PAPERLESS_TOKEN` come from
10
+ * `@distilled.cloud/paperless-ngx`'s `CredentialsFromEnv` (matching.ts), the same two variable
11
+ * names the retired hand-rolled `credentials.ts` read.
11
12
  * ⛔ `defaultRemovalPolicy: 'retain'` — the reconciler this replaces never deletes taxonomy, and
12
13
  * a tag's id is referenced by every document it is attached to.
13
14
  */
14
15
  import { Resource } from 'alchemy';
15
16
  import * as Provider from 'alchemy/Provider';
17
+ import * as tags from '@distilled.cloud/paperless-ngx/tags';
16
18
  import * as Effect from 'effect/Effect';
17
19
  import type { PaperlessTagRequest } from './generated/types/tags.ts';
18
- import { type MatchingProps, type MatchingRequirements, matchingHandlers } from './matching.ts';
20
+ import {
21
+ LOCATE_PAGE,
22
+ type MatchingRequirements,
23
+ type MatchingSpec,
24
+ matchingHandlers,
25
+ } from './matching.ts';
19
26
 
20
- export interface TagProps extends MatchingProps {
27
+ export interface TagProps {
21
28
  /** ⚠️ maxLength 128 (generated constraint table). The locate key — see matching.ts's header. */
22
29
  name: string;
30
+ owner?: number;
23
31
  /** ⚠️ minLength 1, maxLength 7 (a CSS hex color, generated table doesn't enforce the `#`). */
24
32
  color?: string;
25
33
  /** ⚠️ maxLength 256. The text or pattern documents are matched against. */
@@ -54,38 +62,60 @@ export interface Tag extends Resource<
54
62
 
55
63
  export const Tag = Resource<Tag>('Paperless.Tag', { defaultRemovalPolicy: 'retain' });
56
64
 
57
- const handlers = matchingHandlers<TagProps, TagAttributes>({
58
- attributes: (live, props) => {
59
- const id = live['id'];
60
- if (typeof id !== 'number') return undefined;
61
- return {
62
- color: typeof live['color'] === 'string' ? live['color'] : '',
63
- id,
64
- isInboxTag: live['is_inbox_tag'] === true,
65
- isInsensitive: live['is_insensitive'] === true,
66
- match: typeof live['match'] === 'string' ? live['match'] : '',
67
- matchingAlgorithm:
68
- typeof live['matching_algorithm'] === 'number' ? live['matching_algorithm'] : 0,
69
- name: typeof live['name'] === 'string' ? live['name'] : props.name,
70
- owner: typeof live['owner'] === 'number' ? live['owner'] : undefined,
71
- parent: typeof live['parent'] === 'number' ? live['parent'] : undefined,
72
- };
73
- },
74
- collection: 'tags',
65
+ /**
66
+ * ★ EXPORTED, NOT JUST PASSED INLINE — so a test can call `matchingOperations(spec)` directly
67
+ * (tag.test.ts does), against an explicit fake `Credentials` layer, without touching
68
+ * `matchingHandlers`'s baked-in `CredentialsFromEnv` — the same seam `../forgejo/repository.ts`
69
+ * and `../netbox/prefix.ts` use.
70
+ */
71
+ export const spec: MatchingSpec<
72
+ TagProps,
73
+ tags.Tag,
74
+ TagAttributes,
75
+ | tags.CreateTagError
76
+ | tags.GetTagError
77
+ | tags.ListTagsError
78
+ | tags.UpdateTagsPartialError
79
+ | tags.TagsDestroyError
80
+ > = {
81
+ attributes: (live, props) => ({
82
+ color: live.color ?? '',
83
+ id: live.id,
84
+ isInboxTag: live.is_inbox_tag === true,
85
+ isInsensitive: live.is_insensitive === true,
86
+ match: live.match ?? '',
87
+ matchingAlgorithm: live.matching_algorithm ?? 0,
88
+ name: live.name,
89
+ owner: live.owner ?? undefined,
90
+ parent: live.parent ?? undefined,
91
+ }),
92
+ create: (body) => tags.createTag(body as unknown as tags.CreateTagRequest),
75
93
  describe: (props) => `tags ${props.name}`,
94
+ destroy: (id) => tags.tagsDestroy({ id }),
76
95
  endpoint: { create: 'paperless:POST /api/tags/', update: 'paperless:PATCH /api/tags/{id}/' },
77
96
  fields: [
78
- { key: 'color', live: (r) => r['color'], prop: (p) => p.color },
79
- { key: 'match', live: (r) => r['match'], prop: (p) => p.match },
97
+ { key: 'color', live: (r) => r.color, prop: (p) => p.color },
98
+ { key: 'match', live: (r) => r.match, prop: (p) => p.match },
80
99
  {
81
100
  key: 'matching_algorithm',
82
- live: (r) => r['matching_algorithm'],
101
+ live: (r) => r.matching_algorithm,
83
102
  prop: (p) => p.matchingAlgorithm,
84
103
  },
85
- { key: 'is_insensitive', live: (r) => r['is_insensitive'], prop: (p) => p.isInsensitive },
86
- { key: 'is_inbox_tag', live: (r) => r['is_inbox_tag'], prop: (p) => p.isInboxTag },
87
- { key: 'parent', live: (r) => r['parent'], prop: (p) => p.parent },
104
+ { key: 'is_insensitive', live: (r) => r.is_insensitive, prop: (p) => p.isInsensitive },
105
+ { key: 'is_inbox_tag', live: (r) => r.is_inbox_tag, prop: (p) => p.isInboxTag },
106
+ { key: 'parent', live: (r) => r.parent, prop: (p) => p.parent },
88
107
  ],
89
- });
108
+ getById: (id) =>
109
+ tags.getTag({ id }).pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
110
+ list: (props) => tags.listTags({ name__iexact: props.name, page_size: LOCATE_PAGE }),
111
+ ownerOf: (live) => live.owner ?? undefined,
112
+ update: (id, body) =>
113
+ tags.updateTagsPartial({
114
+ ...(body as unknown as Omit<tags.UpdateTagsPartialRequest, 'id'>),
115
+ id,
116
+ }),
117
+ };
118
+
119
+ export const handlers = matchingHandlers(spec);
90
120
 
91
121
  export const TagProvider = () => Provider.effect(Tag, Effect.succeed(Tag.Provider.of(handlers)));
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `Proxmox.Acl` through Alchemy's own Plan and Apply, over a fake cluster driving the REAL
3
+ * `@distilled.cloud/proxmox` protocol (real path assembly, real form-urlencoded PUT body, real
4
+ * `{"data": ...}` envelope) — the same harness `adopt-noop.test.ts` already runs this family
5
+ * through (that file is the cross-family proof that an already-matching grant is read-only; this
6
+ * one is the family's own write/delete/replace/read-back paths).
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import * as Effect from 'effect/Effect';
10
+ import * as Layer from 'effect/Layer';
11
+ import { engineOver } from '../verify/fake-engine.ts';
12
+ import { ProxmoxAcl, ProxmoxAclProvider } from './acl.ts';
13
+ import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
14
+
15
+ type Row = { path: string; type: string; ugid: string; roleid: string; propagate?: number };
16
+
17
+ /** A cluster holding `rows`; a PUT mutates them the way PVE's own ACL handler does. */
18
+ const cluster = (rows: Row[]) =>
19
+ fakePve((call: PveCall) => {
20
+ if (call.method === 'GET' && call.path === 'access/acl') return rows;
21
+ if (call.method !== 'PUT' || call.path !== 'access/acl') return undefined;
22
+ const ugid = call.form['users'] ?? call.form['groups'] ?? call.form['tokens'];
23
+ const type =
24
+ call.form['users'] !== undefined
25
+ ? 'user'
26
+ : call.form['groups'] !== undefined
27
+ ? 'group'
28
+ : 'token';
29
+ const idx = rows.findIndex(
30
+ (r) => r.path === call.form['path'] && r.roleid === call.form['roles'] && r.ugid === ugid,
31
+ );
32
+ if (call.form['delete'] === '1') {
33
+ if (idx !== -1) rows.splice(idx, 1);
34
+ return undefined; // PVE answers {"data": null} on a successful PUT.
35
+ }
36
+ const row: Row = {
37
+ path: call.form['path'] ?? '',
38
+ propagate: call.form['propagate'] === '0' ? 0 : 1,
39
+ roleid: call.form['roles'] ?? '',
40
+ type,
41
+ ugid: ugid ?? '',
42
+ };
43
+ if (idx === -1) rows.push(row);
44
+ else rows[idx] = row;
45
+ return undefined;
46
+ });
47
+
48
+ const grant = (roleid = 'PVEAuditor') =>
49
+ ProxmoxAcl('auditor', { path: '/', roleid, target: FAKE_TARGET, type: 'user', ugid: 'u@pve' });
50
+
51
+ const engineFor = (fake: ReturnType<typeof cluster>) =>
52
+ engineOver(ProxmoxAclProvider().pipe(Layer.provideMerge(fake.layer)));
53
+
54
+ describe('Proxmox.Acl over the distilled protocol', () => {
55
+ test('creating a grant that does not exist yet: one PUT, propagate defaults on', async () => {
56
+ const fake = cluster([]);
57
+ await withoutBao(async () => {
58
+ const engine = engineFor(fake);
59
+ // ⚠️ 'adopted', not 'created' — `diff` answers `undefined` when there is no prior state
60
+ // (there is no create verb for this family to plan one for; see the file header), so
61
+ // Alchemy reports the same label an adoption gets. `reconcile` still PUTs it below.
62
+ expect(await engine.deploy(grant())).toEqual({ auditor: 'adopted' });
63
+ });
64
+ expect(fake.writes()).toEqual(['PUT access/acl']);
65
+ const [call] = fake.calls.filter((c) => c.method === 'PUT');
66
+ expect(call?.form).toMatchObject({
67
+ path: '/',
68
+ propagate: '1',
69
+ roles: 'PVEAuditor',
70
+ users: 'u@pve',
71
+ });
72
+ });
73
+
74
+ test('a drifted propagate is corrected with exactly one PUT', async () => {
75
+ const fake = cluster([
76
+ { path: '/', propagate: 0, roleid: 'PVEAuditor', type: 'user', ugid: 'u@pve' },
77
+ ]);
78
+ await withoutBao(async () => {
79
+ const engine = engineFor(fake);
80
+ const report = await engine.verify(grant());
81
+ expect(report.rows[0]).toMatchObject({ diff: 'update' });
82
+ expect(await engine.deploy(grant())).toEqual({ auditor: 'adopted' });
83
+ });
84
+ expect(fake.writes()).toEqual(['PUT access/acl']);
85
+ const [putCall] = fake.calls.filter((c) => c.method === 'PUT');
86
+ expect(putCall?.form['propagate']).toBe('1');
87
+ });
88
+
89
+ test('orphaning: the delete PUT carries delete=1, no propagate field, and removes the row', async () => {
90
+ const fake = cluster([]);
91
+ await withoutBao(async () => {
92
+ const engine = engineFor(fake);
93
+ await engine.deploy(grant());
94
+ expect(await engine.deploy(Effect.void)).toEqual({});
95
+ });
96
+ const [, deleteCall] = fake.calls.filter((c) => c.method === 'PUT');
97
+ expect(deleteCall?.form).toMatchObject({
98
+ delete: '1',
99
+ path: '/',
100
+ roles: 'PVEAuditor',
101
+ users: 'u@pve',
102
+ });
103
+ expect(deleteCall?.form['propagate']).toBeUndefined();
104
+ expect(fake.calls.filter((c) => c.method === 'GET').at(-1)).toBeDefined();
105
+ });
106
+
107
+ test('an identity change (roleid) replaces: the old grant is gone, the new one is bound', async () => {
108
+ const fake = cluster([]);
109
+ await withoutBao(async () => {
110
+ const engine = engineFor(fake);
111
+ await engine.deploy(grant('PVEAuditor'));
112
+ // ⚠️ `{ all: true }` — this row already has state, and `verify` reports only stateless
113
+ // rows by default (verify.ts's `VerifyOptions.all`).
114
+ const report = await engine.verify(grant('PVEAdmin'), { all: true });
115
+ expect(report.rows[0]).toMatchObject({ diff: 'replace' });
116
+ await engine.deploy(grant('PVEAdmin'));
117
+ // The live cluster now holds only the new grant — a fresh adopt of it plans noop.
118
+ const after = await engine.verify(grant('PVEAdmin'), { all: true });
119
+ expect(after.rows[0]).toMatchObject({ diff: 'noop' });
120
+ });
121
+ // Create-first (Alchemy's default): the new grant is PUT before the old one is removed.
122
+ const puts = fake.calls.filter((c) => c.method === 'PUT');
123
+ expect(puts.length).toBeGreaterThanOrEqual(2);
124
+ expect(puts.some((c) => c.form['roles'] === 'PVEAdmin' && c.form['delete'] === undefined)).toBe(
125
+ true,
126
+ );
127
+ expect(puts.some((c) => c.form['roles'] === 'PVEAuditor' && c.form['delete'] === '1')).toBe(
128
+ true,
129
+ );
130
+ });
131
+
132
+ test('a PUT that silently no-ops is refused, not recorded as landed', async () => {
133
+ // ⚠️ A cluster whose PUT never actually lands the row — the permission-filtered case the
134
+ // header describes: PVE answers 200 with no error, but the grant never appears on GET.
135
+ const fake = fakePve((call) =>
136
+ call.method === 'GET' && call.path === 'access/acl' ? [] : undefined,
137
+ );
138
+ await expect(
139
+ withoutBao(async () => {
140
+ const engine = engineFor(fake);
141
+ await engine.deploy(grant());
142
+ }),
143
+ ).rejects.toThrow(/still not bound/);
144
+ });
145
+ });