@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
@@ -2,7 +2,15 @@
2
2
  * One shape for every Paperless taxonomy object (Tag, DocumentType, StoragePath, CustomField):
3
3
  * locate by `name__iexact` only while there is no prior state, identify the exact (name, owner),
4
4
  * create-or-update, never delete by default. Modelled on `../netbox/resource.ts`'s
5
- * `netboxOperations`, with the differences this unit's spec calls for.
5
+ * `netboxOperations` and `../forgejo/resource.ts`'s `forgejoOperations`.
6
+ *
7
+ * 🔴 WHY THIS REPLACED `client.ts`'s HAND-ROLLED `HttpClient` CALLS (2026-09-24, decision 43).
8
+ * Measured against Tim's distilled research page 2026-09-23: upstream Alchemy only accepts
9
+ * providers that call `@distilled.cloud/<vendor>` operations and `catchTag` their typed errors
10
+ * — not a raw `fetch`/`HttpClient` client over a hand-maintained path table.
11
+ * `@distilled.cloud/paperless-ngx` (aliased onto `@homeflare/distilled-paperless-ngx@0.3.0` —
12
+ * docs/distilled-interim.md) covers every operation this family calls; its 404/400 shapes were
13
+ * already live-measured (kit PR 194) before this migration touched a resource file.
6
14
  *
7
15
  * ⛔ THE CREATE BODY ALWAYS CARRIES `owner`, NULL WHEN UNDECLARED. `OwnedObjectSerializer.create`
8
16
  * sets the owner to the TOKEN USER when the field is absent from the body (Paperless-ngx
@@ -10,60 +18,38 @@
10
18
  * omits an undeclared foreign key would silently create an object owned by whichever credential
11
19
  * ran the deploy, invisible to every other user and a second row beside the estate's `owner:
12
20
  * null` one on the next apply. Sending `owner: null` explicitly is what keeps a create
13
- * idempotent under this vendor's default.
21
+ * idempotent under this vendor's default. Unchanged by the transport swap.
14
22
  *
15
23
  * ★ READ ANSWERS `Unowned(attrs)` ON EVERY MATCH (H1, alchemy-provider-standard). Paperless has no
16
24
  * ownership marker this package could check — `owner` here is the estate's OWN prop, not proof
17
25
  * of who created the row — so, per the house's 2026-09-21 decision, identical is not ours: a
18
- * live match never adopts silently. `--adopt` (or `adopt(true)`) resolves it, same as any other
19
- * marker-less API (upstream's `Snippet.ts#read`).
26
+ * live match never adopts silently.
20
27
  *
21
28
  * ⛔ IDENTITY IS THE STORED `id`, NOT `name`/`owner`, ONCE A RESOURCE HAS STATE (PR 163, red-team
22
- * HIGH finding). `diff` and `reconcile` used to re-derive identity from `news` alone on EVERY
23
- * call — `fetchLive` located a live row by `(name, owner)` even when `output` (the engine's
24
- * record of what this declaration last wrote, `Provider.ts#reconcile`'s `output` parameter) was
25
- * right there holding the object's real id. Editing `name` (or `owner`, for the three owned
26
- * families) on an already-deployed resource is the single most ordinary edit anyone would make
27
- * — and it made `fetchLive(news)` miss the row it previously wrote, fall into the create branch,
28
- * POST a SECOND object under the new name/owner, and leave the original permanently orphaned
29
- * (`list()` always answers `[]` by design, so `nuke` can never find it either). `diff` reported
30
- * a plain `{action:'update'}` — nothing told the operator this was about to duplicate and
31
- * abandon an object.
32
- *
33
- * The fix: once `output` is defined, `fetchLive` locates by `output.id` (a direct
34
- * `GET {collection}/{id}/`), never by name — a changed `name`/`owner` becomes an ordinary PATCH
35
- * of that same id (Paperless allows PATCHing both on all four types; documents keep their
36
- * tag/type/path because the id never changes). `patchBody`/`matches` now compare `name` and
37
- * (when `spec.owned !== false`) `owner` against the live row too, so the rename/re-own actually
38
- * reaches the PATCH body — locating the right row was necessary but not sufficient. The
39
- * name/owner-based `fetchByName` locate survives ONLY for the no-state path: a genuine first
40
- * create, or the engine's own adoption probe (`provider.read`, called with `output: undefined`
41
- * per `Plan.ts`'s adopt block) — `read` is unchanged and still locates by name for exactly that
42
- * reason. If `output.id` no longer exists live (deleted out of band — by hand, or by another
43
- * tool), `fetchLive` refuses loudly (`Effect.die`) rather than silently falling through to a
44
- * create: a plan that would otherwise read as "ordinary update" instead names the id and tells
45
- * the operator to adopt or clean up state, the same posture `immutable` already uses below for
46
- * `CustomField.dataType`.
29
+ * HIGH finding — see `matching-locate.ts`'s `fetchLive` for the fix and the full history). This
30
+ * migration keeps that fix byte-for-byte: only the wire call under `fetchByName`/`fetchById`
31
+ * changed, not the switch itself.
47
32
  */
48
33
  import { Unowned } from 'alchemy/AdoptPolicy';
49
34
  import { isResolved } from 'alchemy/Diff';
50
35
  import type { Input } from 'alchemy/Input';
36
+ import { CredentialsFromEnv } from '@distilled.cloud/paperless-ngx/Credentials';
37
+ import type { PaperlessNgxOpContext } from '@distilled.cloud/paperless-ngx/Protocol';
51
38
  import * as Effect from 'effect/Effect';
52
- import type { PaperlessRow } from './client.ts';
53
- import { paperless } from './client.ts';
54
39
  import { guardBody } from './constraint-guard.ts';
55
40
  import type { PaperlessBody } from './constraints.ts';
56
- import { absentOn404, matchingLocate, ownerOf } from './matching-locate.ts';
41
+ import { fetchLive as identitySwitch, locateOne } from './matching-locate.ts';
57
42
  import type { MatchingProps, MatchingSpec } from './matching-types.ts';
58
43
 
59
44
  /** Re-exported so `tag.ts`/`document-type.ts`/`storage-path.ts`/`custom-field.ts` keep importing the contract from `./matching.ts` — only the shape itself lives in `matching-types.ts`, split out to hold the file-size cap. */
60
45
  export type {
61
46
  FieldSpec,
62
- MatchingError,
47
+ MatchingPage,
63
48
  MatchingProps,
64
49
  MatchingRequirements,
65
50
  MatchingSpec,
66
51
  } from './matching-types.ts';
52
+ export { LOCATE_PAGE, soleMatch } from './matching-locate.ts';
67
53
 
68
54
  /** Object equality good enough for this family's scalars, nullable ints and `extra_data` JSON. */
69
55
  const sameValue = (a: unknown, b: unknown): boolean =>
@@ -71,15 +57,35 @@ const sameValue = (a: unknown, b: unknown): boolean =>
71
57
 
72
58
  export const matchingOperations = <
73
59
  Props extends MatchingProps,
60
+ Live extends { readonly id: number; readonly name: string },
74
61
  Attributes extends { readonly id: number },
62
+ E,
75
63
  >(
76
- spec: MatchingSpec<Props, Attributes>,
64
+ spec: MatchingSpec<Props, Live, Attributes, E>,
77
65
  ) => {
78
- // ★ IDENTITY RESOLUTION LIVES IN `matching-locate.ts` (file-size cap) — `fetchByName` for the
79
- // no-state path, `fetchLive` for the `output.id`-first switch this file's header describes.
80
- const { fetchByName, fetchById, fetchLive, objectPath } = matchingLocate<Props>(spec);
66
+ const ownerOf = (live: Live): number | undefined =>
67
+ spec.owned === false || spec.ownerOf === undefined ? undefined : spec.ownerOf(live);
68
+
69
+ const ownerMatches = (live: Live, props: Props): boolean =>
70
+ spec.owned === false || spec.ownerOf === undefined
71
+ ? true
72
+ : spec.ownerOf(live) === (props.owner ?? undefined);
73
+
74
+ /** ★ THE NO-STATE LOCATE — see matching-locate.ts's `locateOne` and this file's own header. */
75
+ const fetchByName = (props: Props) =>
76
+ locateOne(
77
+ spec.list(props),
78
+ spec.describe(props),
79
+ (row) => row.name === props.name && ownerMatches(row, props),
80
+ );
81
81
 
82
- /** ⚠️ ALWAYS NAME-BASED — this is `read`'s own contract (S7): find the object from props alone, with no `output` to consult. Unaffected by the identity switch above. */
82
+ /** Already folds a 404 — each resource file's own `.pipe(Effect.catchTag('NotFound', …))`. */
83
+ const fetchById = spec.getById;
84
+
85
+ const fetchLive = (props: Props, output: Attributes | undefined) =>
86
+ identitySwitch(props, output, fetchByName, fetchById, spec.describe);
87
+
88
+ /** ⚠️ ALWAYS NAME-BASED — this is `read`'s own contract (S7): find the object from props alone, with no `output` to consult. */
83
89
  const read = (props: Props) =>
84
90
  fetchByName(props).pipe(
85
91
  Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
@@ -102,17 +108,10 @@ export const matchingOperations = <
102
108
  return out;
103
109
  };
104
110
 
105
- /**
106
- * ⚠️ Only fields that DIFFER from the live row — a PATCH that repeats an unchanged value is
107
- * still a write the audit log carries for nothing.
108
- * ⛔ `name` AND (WHEN OWNED) `owner` ARE COMPARED TOO, NOT JUST `spec.fields`. Locating the
109
- * right row by `id` fixed WHERE the write lands; without this, a rename/re-own would still
110
- * locate correctly and then send an empty-for-those-keys PATCH, so the live object's name or
111
- * owner would silently never change (PR 163).
112
- */
113
- const patchBody = (props: Props, live: PaperlessRow): PaperlessBody => {
111
+ /** ⚠️ Only fields that DIFFER from the live row. `name`/`owner` are compared too (PR 163). */
112
+ const patchBody = (props: Props, live: Live): PaperlessBody => {
114
113
  const out: PaperlessBody = {};
115
- if (props.name !== live['name']) out['name'] = props.name;
114
+ if (props.name !== live.name) out['name'] = props.name;
116
115
  if (spec.owned !== false && ownerOf(live) !== (props.owner ?? undefined)) {
117
116
  out['owner'] = props.owner ?? null;
118
117
  }
@@ -123,8 +122,8 @@ export const matchingOperations = <
123
122
  return out;
124
123
  };
125
124
 
126
- const matches = (props: Props, live: PaperlessRow): boolean => {
127
- if (props.name !== live['name']) return false;
125
+ const matches = (props: Props, live: Live): boolean => {
126
+ if (props.name !== live.name) return false;
128
127
  if (spec.owned !== false && ownerOf(live) !== (props.owner ?? undefined)) return false;
129
128
  return spec.fields.every((field) => {
130
129
  const value = field.prop(props);
@@ -143,7 +142,7 @@ export const matchingOperations = <
143
142
  const refusal = spec.immutable?.(live, news);
144
143
  if (refusal !== undefined) return yield* Effect.die(new Error(refusal));
145
144
  if (matches(news, live)) return { action: 'noop' } as const;
146
- return spec.endpoint.update === undefined
145
+ return spec.update === undefined
147
146
  ? ({ action: 'replace' } as const)
148
147
  : ({ action: 'update' } as const);
149
148
  }),
@@ -155,25 +154,19 @@ export const matchingOperations = <
155
154
  if (before === undefined) {
156
155
  const body = createBody(news);
157
156
  yield* guardBody(spec.endpoint.create, body, true);
158
- yield* paperless('POST', `${spec.collection}/`, body);
157
+ yield* spec.create(body);
159
158
  } else {
160
- id = typeof before['id'] === 'number' ? before['id'] : undefined;
161
- // ⚠️ RE-CHECKED HERE, NOT ONLY IN `diff` — an adoption's first reconcile can run with
162
- // no prior `diff` at all, and `immutable` guards a write that would destroy data.
159
+ id = before.id;
163
160
  const refusal = spec.immutable?.(before, news);
164
161
  if (refusal !== undefined) return yield* Effect.die(new Error(refusal));
165
- if (spec.endpoint.update !== undefined) {
162
+ if (spec.update !== undefined) {
166
163
  const body = patchBody(news, before);
167
164
  if (Object.keys(body).length > 0) {
168
165
  yield* guardBody(spec.endpoint.update, body, false);
169
- yield* paperless('PATCH', `${objectPath(before)}/`, body);
166
+ yield* spec.update(id, body);
170
167
  }
171
168
  }
172
169
  }
173
- // ★ READ BACK BY id WHEN WE HAVE ONE — a just-PATCHed row is read back by the id we
174
- // wrote to, not by (possibly just-changed) name, so a rename's own read-back cannot
175
- // fail the way the write it follows no longer can either. A fresh create has no id yet
176
- // to read back by, so that branch still reads back by the name it just created.
177
170
  const after = yield* id === undefined ? read(news) : readById(news, id);
178
171
  if (after === undefined) {
179
172
  return yield* Effect.die(
@@ -185,55 +178,49 @@ export const matchingOperations = <
185
178
  return after;
186
179
  }),
187
180
 
188
- /**
189
- * ⛔ `output.id` FIRST, SAME SWITCH AS `fetchLive` (PR 163 re-review). The engine always
190
- * passes `output` here too (`Apply.ts`'s delete call sites all carry `output: attr`) —
191
- * `olds` is only the PROPS as last persisted, which drifts from the live object exactly
192
- * the way `news` did pre-fix: rename it out of band (by hand, or by another tool) after
193
- * the last successful reconcile, and a name-only locate silently finds nothing, `destroy`
194
- * returns as if there were nothing to delete, and the engine drops the state row anyway —
195
- * reporting "deleted" while the live object survives, permanently orphaned (the same
196
- * consequence `matching.ts`'s header describes for the original bug, on the delete path
197
- * the original fix didn't touch). A 404 on the `output.id` itself is NOT an error here,
198
- * unlike `fetchLive`'s die — it means the object is already gone, which is exactly the
199
- * idempotent success `delete` is supposed to report (S11, alchemy-provider-standard).
200
- */
181
+ /** ⛔ `output.id` FIRST, SAME SWITCH AS `fetchLive` (PR 163 re-review). A 404 on `output.id` is idempotent success, not an error, unlike `fetchLive`'s die. */
201
182
  destroy: (olds: Props, output: Attributes | undefined) =>
202
183
  Effect.gen(function* () {
203
184
  const live = output === undefined ? yield* fetchByName(olds) : yield* fetchById(output.id);
204
185
  if (live === undefined) return;
205
- yield* absentOn404(paperless('DELETE', `${objectPath(live)}/`));
186
+ yield* spec.destroy(live.id);
206
187
  }),
207
188
  };
208
189
  };
209
190
 
210
- /** The five provider handlers for a spec'd Paperless taxonomy object, wired once. */
191
+ /**
192
+ * The five provider handlers for a spec'd Paperless taxonomy object, wired once.
193
+ *
194
+ * ★ CREDENTIALS ARE PROVIDED HERE, ONCE, NOT BY EVERY CALLER — the same shape
195
+ * `netboxHandlers`/`forgejoHandlers` use. `CredentialsFromEnv` resolves `PAPERLESS_URL` /
196
+ * `PAPERLESS_TOKEN` on the calling fiber per request, so this closure keeps that laziness —
197
+ * nothing is captured at module load. ⛔ THE TOKEN IS NEVER A PROP.
198
+ */
211
199
  export const matchingHandlers = <
212
200
  Props extends MatchingProps,
201
+ Live extends { readonly id: number; readonly name: string },
213
202
  Attributes extends { readonly id: number },
203
+ E,
214
204
  >(
215
- spec: MatchingSpec<Props, Attributes>,
205
+ spec: MatchingSpec<Props, Live, Attributes, E>,
216
206
  ) => {
217
207
  const ops = matchingOperations(spec);
208
+ const withCredentials = <A>(effect: Effect.Effect<A, E, PaperlessNgxOpContext>) =>
209
+ Effect.provide(effect, CredentialsFromEnv);
218
210
  return {
219
211
  /** ⚠️ `[]` — Paperless index endpoints return the whole estate; adoption stays explicit. */
220
212
  list: () => Effect.succeed([]),
221
213
  read: ({ olds }: { olds: Props }) =>
222
- ops
223
- .read(olds)
224
- .pipe(Effect.map((attrs) => (attrs === undefined ? undefined : Unowned(attrs)))),
214
+ withCredentials(
215
+ ops
216
+ .read(olds)
217
+ .pipe(Effect.map((attrs) => (attrs === undefined ? undefined : Unowned(attrs)))),
218
+ ),
225
219
  diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
226
- ops.diff(news, output),
227
- // ⛔ `output` IS WHAT MAKES THE IDENTITY FIX WORK (PR 163) — the engine always passes it
228
- // (`Provider.ts#reconcile`), so it is optional here only so a test can still call
229
- // `reconcile({ news })` for a from-nothing create without spelling out `output: undefined`.
230
- // ⚠️ `| undefined` on the optional type itself, not just `?` — `exactOptionalPropertyTypes`
231
- // otherwise refuses the engine's own `output: Attributes | undefined` at the call site.
220
+ withCredentials(ops.diff(news, output)),
232
221
  reconcile: ({ news, output }: { news: Props; output?: Attributes | undefined }) =>
233
- ops.reconcile(news, output),
234
- // ⚠️ `output` PASSED THROUGH, NOT DROPPED — see `destroy`'s own header above (PR 163
235
- // re-review). Same `| undefined` reasoning as `reconcile` just above.
222
+ withCredentials(ops.reconcile(news, output)),
236
223
  delete: ({ olds, output }: { olds: Props; output?: Attributes | undefined }) =>
237
- ops.destroy(olds, output),
224
+ withCredentials(ops.destroy(olds, output)),
238
225
  };
239
226
  };
@@ -1,27 +1,31 @@
1
1
  /**
2
- * All four Paperless taxonomy providers, one credentials layer shared between them.
2
+ * All four Paperless taxonomy providers.
3
3
  *
4
4
  * Layer.mergeAll(paperlessProviders(), …the stack's other providers)
5
5
  *
6
6
  * ⚠️ `HttpClient.HttpClient` IS NOT BUNDLED HERE, and that follows `../netbox/index.ts` and
7
- * `../forgejo/client.ts`'s own precedent (its header: "the idiom Alchemy's own providers
8
- * follow — `Hetzner/Providers.ts` builds on `FetchHttpClient.layer`") rather than diverging
9
- * from it: the transport is the consuming STACK's choice (`FetchHttpClient.layer` in
10
- * production, a fake `HttpClient` in a test), not a house default a provider file should pin.
7
+ * `../forgejo/client.ts`'s own precedent: the transport is the consuming STACK's choice
8
+ * (`FetchHttpClient.layer` in production, a fake `HttpClient` in a test), not a house default a
9
+ * provider file should pin.
10
+ *
11
+ * 🔴 NO `credentials` PARAMETER ANY MORE (2026-09-24, the `@distilled.cloud/paperless-ngx`
12
+ * migration — see `matching.ts`'s header). Each of the four `xxxProvider()`s now bakes in
13
+ * `CredentialsFromEnv` itself, the same way `netboxHandlers`/`forgejoHandlers` do — there is no
14
+ * longer a layer for this file to merge in or accept an override for. Nothing in this estate
15
+ * called `paperlessProviders(customLayer)` (measured 2026-09-24: no tray repo declares a
16
+ * Paperless resource at all — grep across `homeflare-landscape`'s tray repos' `origin/main`),
17
+ * so this is an uneventful simplification, not a behaviour change for anything live.
11
18
  */
12
19
  import * as Layer from 'effect/Layer';
13
20
  import { CustomFieldProvider } from './custom-field.ts';
14
21
  import { DocumentTypeProvider } from './document-type.ts';
15
- import { type PaperlessCredentials, environmentLayer } from './credentials.ts';
16
22
  import { StoragePathProvider } from './storage-path.ts';
17
23
  import { TagProvider } from './tag.ts';
18
24
 
19
- export const paperlessProviders = (
20
- credentials: Layer.Layer<PaperlessCredentials> = environmentLayer,
21
- ) =>
25
+ export const paperlessProviders = () =>
22
26
  Layer.mergeAll(
23
27
  TagProvider(),
24
28
  DocumentTypeProvider(),
25
29
  StoragePathProvider(),
26
30
  CustomFieldProvider(),
27
- ).pipe(Layer.provide(credentials));
31
+ );
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `StoragePath`'s exported production `spec` against a fake Paperless-ngx — proves this file's
3
+ * own `@distilled.cloud/paperless-ngx/storage_paths` wiring (right operations, right field names,
4
+ * including the vendor-required `path` template) round-trips for real. See document-type.test.ts
5
+ * and tag.ts's own header for why the shared engine and PR 163 are not re-proven here.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { fakePaperless, run } from './fake-paperless.ts';
9
+ import { matchingOperations } from './matching.ts';
10
+ import { spec } from './storage-path.ts';
11
+
12
+ const ops = matchingOperations(spec);
13
+
14
+ test('create sends the required path field alongside owner: null', async () => {
15
+ const fake = fakePaperless();
16
+ await run(
17
+ ops.reconcile({ name: 'By correspondent', path: '{correspondent}/{title}' }, undefined),
18
+ fake.fetch,
19
+ );
20
+ const post = fake.seen.find((s) => s.method === 'POST');
21
+ expect(JSON.parse(post?.body ?? '{}')).toMatchObject({
22
+ name: 'By correspondent',
23
+ owner: null,
24
+ path: '{correspondent}/{title}',
25
+ });
26
+ });
27
+
28
+ describe('a full create → read → patch → delete round trip', () => {
29
+ test('the real distilled wire path carries the path template through a rename', async () => {
30
+ const fake = fakePaperless();
31
+ const created = await run(
32
+ ops.reconcile({ name: 'By correspondent', path: '{correspondent}/{title}' }, undefined),
33
+ fake.fetch,
34
+ );
35
+ expect(created).toMatchObject({ name: 'By correspondent', path: '{correspondent}/{title}' });
36
+
37
+ const after = await run(
38
+ ops.reconcile({ name: 'By correspondent', path: '{correspondent}/{created}' }, created),
39
+ fake.fetch,
40
+ );
41
+ const patch = fake.seen.find((s) => s.method === 'PATCH');
42
+ expect(JSON.parse(patch?.body ?? '{}')).toEqual({ path: '{correspondent}/{created}' });
43
+ expect(after?.path).toBe('{correspondent}/{created}');
44
+
45
+ await run(
46
+ ops.destroy({ name: 'By correspondent', path: '{correspondent}/{created}' }, after),
47
+ fake.fetch,
48
+ );
49
+ expect(fake.rows('storage_paths').length).toBe(0);
50
+ });
51
+ });
52
+
53
+ test('a 404 on read folds to absent', async () => {
54
+ const fake = fakePaperless();
55
+ expect(await run(ops.read({ name: 'Nothing here', path: 'x' }), fake.fetch)).toBeUndefined();
56
+ });
@@ -1,16 +1,24 @@
1
1
  /**
2
2
  * `Paperless.StoragePath` — a Paperless-ngx storage path template, create-or-update and never
3
- * deleted. Same shape as `document-type.ts` plus the vendor-required `path` template string.
3
+ * deleted. Same shape as `document-type.ts` plus the vendor-required `path` template string; see
4
+ * `tag.ts`'s header for the 2026-09-24 transport migration this file follows exactly.
4
5
  */
5
6
  import { Resource } from 'alchemy';
6
7
  import * as Provider from 'alchemy/Provider';
8
+ import * as storagePaths from '@distilled.cloud/paperless-ngx/storage_paths';
7
9
  import * as Effect from 'effect/Effect';
8
10
  import type { PaperlessStoragePathRequest } from './generated/types/storage-paths.ts';
9
- import { type MatchingProps, type MatchingRequirements, matchingHandlers } from './matching.ts';
11
+ import {
12
+ LOCATE_PAGE,
13
+ type MatchingRequirements,
14
+ type MatchingSpec,
15
+ matchingHandlers,
16
+ } from './matching.ts';
10
17
 
11
- export interface StoragePathProps extends MatchingProps {
18
+ export interface StoragePathProps {
12
19
  /** ⚠️ maxLength 128 (generated constraint table). The locate key. */
13
20
  name: string;
21
+ owner?: number;
14
22
  /** ⚠️ minLength 1 (generated table). Paperless's storage-path template syntax, e.g. `{correspondent}/{title}`. */
15
23
  path: string;
16
24
  /** ⚠️ maxLength 256. */
@@ -41,38 +49,59 @@ export const StoragePath = Resource<StoragePath>('Paperless.StoragePath', {
41
49
  defaultRemovalPolicy: 'retain',
42
50
  });
43
51
 
44
- const handlers = matchingHandlers<StoragePathProps, StoragePathAttributes>({
45
- attributes: (live, props) => {
46
- const id = live['id'];
47
- if (typeof id !== 'number') return undefined;
48
- return {
49
- id,
50
- isInsensitive: live['is_insensitive'] === true,
51
- match: typeof live['match'] === 'string' ? live['match'] : '',
52
- matchingAlgorithm:
53
- typeof live['matching_algorithm'] === 'number' ? live['matching_algorithm'] : 0,
54
- name: typeof live['name'] === 'string' ? live['name'] : props.name,
55
- owner: typeof live['owner'] === 'number' ? live['owner'] : undefined,
56
- path: typeof live['path'] === 'string' ? live['path'] : props.path,
57
- };
58
- },
59
- collection: 'storage_paths',
52
+ /** ★ Exported for direct testing — see tag.ts's own note on this seam. */
53
+ export const spec: MatchingSpec<
54
+ StoragePathProps,
55
+ storagePaths.StoragePath,
56
+ StoragePathAttributes,
57
+ | storagePaths.CreateStoragePathError
58
+ | storagePaths.GetStoragePathError
59
+ | storagePaths.ListStoragePathsError
60
+ | storagePaths.UpdateStoragePathsPartialError
61
+ | storagePaths.StoragePathsDestroyError
62
+ > = {
63
+ attributes: (live, props) => ({
64
+ id: live.id,
65
+ isInsensitive: live.is_insensitive === true,
66
+ match: live.match ?? '',
67
+ matchingAlgorithm: live.matching_algorithm ?? 0,
68
+ name: live.name,
69
+ owner: live.owner ?? undefined,
70
+ path: live.path,
71
+ }),
72
+ create: (body) =>
73
+ storagePaths.createStoragePath(body as unknown as storagePaths.CreateStoragePathRequest),
60
74
  describe: (props) => `storage_paths ${props.name}`,
75
+ destroy: (id) => storagePaths.storagePathsDestroy({ id }),
61
76
  endpoint: {
62
77
  create: 'paperless:POST /api/storage_paths/',
63
78
  update: 'paperless:PATCH /api/storage_paths/{id}/',
64
79
  },
65
80
  fields: [
66
- { key: 'path', live: (r) => r['path'], prop: (p) => p.path },
67
- { key: 'match', live: (r) => r['match'], prop: (p) => p.match },
81
+ { key: 'path', live: (r) => r.path, prop: (p) => p.path },
82
+ { key: 'match', live: (r) => r.match, prop: (p) => p.match },
68
83
  {
69
84
  key: 'matching_algorithm',
70
- live: (r) => r['matching_algorithm'],
85
+ live: (r) => r.matching_algorithm,
71
86
  prop: (p) => p.matchingAlgorithm,
72
87
  },
73
- { key: 'is_insensitive', live: (r) => r['is_insensitive'], prop: (p) => p.isInsensitive },
88
+ { key: 'is_insensitive', live: (r) => r.is_insensitive, prop: (p) => p.isInsensitive },
74
89
  ],
75
- });
90
+ getById: (id) =>
91
+ storagePaths
92
+ .getStoragePath({ id })
93
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
94
+ list: (props) =>
95
+ storagePaths.listStoragePaths({ name__iexact: props.name, page_size: LOCATE_PAGE }),
96
+ ownerOf: (live) => live.owner ?? undefined,
97
+ update: (id, body) =>
98
+ storagePaths.updateStoragePathsPartial({
99
+ ...(body as unknown as Omit<storagePaths.UpdateStoragePathsPartialRequest, 'id'>),
100
+ id,
101
+ }),
102
+ };
103
+
104
+ export const handlers = matchingHandlers(spec);
76
105
 
77
106
  export const StoragePathProvider = () =>
78
107
  Provider.effect(StoragePath, Effect.succeed(StoragePath.Provider.of(handlers)));
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Regression suite for the PR 163 red-team HIGH finding, re-proved against
3
+ * `@distilled.cloud/paperless-ngx`'s real wire path: `diff`/`reconcile` re-derived identity from
4
+ * `news` alone on every call, so renaming (or re-owning) an already-deployed Tag missed the row
5
+ * it previously wrote, POSTed a second object under the new name/owner, and left the original
6
+ * permanently orphaned — while `diff` reported a plain `{action:'update'}`. See `matching.ts`'s
7
+ * header and `matching-locate.ts`'s `fetchLive` for the fix: once `output` is defined, identity
8
+ * is `output.id`, not `(name, owner)`.
9
+ *
10
+ * "Someone else changed the live row out of band" is simulated with the SDK's own
11
+ * `tags.updateTagsPartial`/`tags.tagsDestroy` against the fake, not an internal escape hatch —
12
+ * this package no longer has one after the `client.ts` removal, and calling the real production
13
+ * functions is the more faithful simulation anyway.
14
+ */
15
+ import { describe, expect, test } from 'bun:test';
16
+ import * as tags from '@distilled.cloud/paperless-ngx/tags';
17
+ import { fakePaperless, run } from './fake-paperless.ts';
18
+ import { matchingOperations } from './matching.ts';
19
+ import { spec } from './tag.ts';
20
+
21
+ const ops = matchingOperations(spec);
22
+ const writesOf = (fake: { seen: readonly { method: string }[] }) =>
23
+ fake.seen.filter((s) => s.method !== 'GET');
24
+
25
+ describe('renaming or re-owning an already-deployed object (PR 163)', () => {
26
+ test('a rename PATCHes the same id — no POST, no orphan', async () => {
27
+ const fake = fakePaperless();
28
+ const created = await run(ops.reconcile({ name: 'Invoices' }, undefined), fake.fetch);
29
+ const before = writesOf(fake).length;
30
+
31
+ const renamed = await run(ops.reconcile({ name: 'Invoices - Renamed' }, created), fake.fetch);
32
+
33
+ const writes = writesOf(fake);
34
+ expect(writes.length).toBe(before + 1);
35
+ expect(writes.slice(before).every((w) => w.method === 'PATCH')).toBe(true);
36
+ expect(fake.rows('tags').length).toBe(1);
37
+ expect(renamed?.id).toBe(created?.id);
38
+ expect(renamed?.name).toBe('Invoices - Renamed');
39
+ });
40
+
41
+ test('an owner change PATCHes the same id — no POST, no orphan', async () => {
42
+ const fake = fakePaperless();
43
+ const created = await run(ops.reconcile({ name: 'Invoices', owner: 7 }, undefined), fake.fetch);
44
+ const before = writesOf(fake).length;
45
+
46
+ const reowned = await run(ops.reconcile({ name: 'Invoices', owner: 9 }, created), fake.fetch);
47
+
48
+ expect(writesOf(fake).length).toBe(before + 1);
49
+ expect(fake.rows('tags').length).toBe(1);
50
+ expect(reowned?.id).toBe(created?.id);
51
+ expect(reowned?.owner).toBe(9);
52
+ });
53
+ });
54
+
55
+ describe('a deleted-out-of-band object (PR 163)', () => {
56
+ test('diff refuses at plan rather than creating a replacement', async () => {
57
+ const fake = fakePaperless();
58
+ const created = await run(ops.reconcile({ name: 'Invoices' }, undefined), fake.fetch);
59
+ if (created === undefined) throw new Error('unreachable: reconcile always returns attrs');
60
+ await run(tags.tagsDestroy({ id: created.id }), fake.fetch);
61
+
62
+ await expect(run(ops.diff({ name: 'Invoices' }, created), fake.fetch)).rejects.toThrow(
63
+ /no longer exists live|deleted out of band/,
64
+ );
65
+ expect(fake.rows('tags').length).toBe(0);
66
+ });
67
+
68
+ test('reconcile also refuses, not only diff, if called with no prior diff', async () => {
69
+ const fake = fakePaperless();
70
+ const created = await run(ops.reconcile({ name: 'Invoices' }, undefined), fake.fetch);
71
+ if (created === undefined) throw new Error('unreachable: reconcile always returns attrs');
72
+ await run(tags.tagsDestroy({ id: created.id }), fake.fetch);
73
+
74
+ await expect(run(ops.reconcile({ name: 'Invoices' }, created), fake.fetch)).rejects.toThrow(
75
+ /no longer exists live|deleted out of band/,
76
+ );
77
+ });
78
+ });
79
+
80
+ describe('the no-state path is unaffected (PR 163)', () => {
81
+ test('a first create still locates by name', async () => {
82
+ const fake = fakePaperless();
83
+ await run(ops.reconcile({ name: 'Invoices' }, undefined), fake.fetch);
84
+
85
+ const get = fake.seen.find((s) => s.method === 'GET');
86
+ expect(get?.path).toContain('name__iexact=Invoices');
87
+ expect(fake.seen.find((s) => s.method === 'POST')).toBeDefined();
88
+ });
89
+
90
+ test("adopt (output from the engine's own read) locates the id it read, no duplicate", async () => {
91
+ const fake = fakePaperless();
92
+ // A pre-existing object, created out of band — what `provider.read` (always called with
93
+ // `output: undefined`) discovers during its adoption probe.
94
+ await run(tags.createTag({ name: 'Invoices', owner: null }), fake.fetch);
95
+ const readAttrs = await run(ops.read({ name: 'Invoices' }), fake.fetch);
96
+ if (readAttrs === undefined) throw new Error('unreachable: asserted defined above');
97
+
98
+ const after = await run(ops.reconcile({ name: 'Invoices' }, readAttrs), fake.fetch);
99
+
100
+ expect(fake.rows('tags').length).toBe(1);
101
+ expect(after?.id).toBe(readAttrs.id);
102
+ expect(fake.seen.filter((s) => s.method === 'POST').length).toBe(1);
103
+ });
104
+ });
105
+
106
+ describe('delete locates by output.id, not by (possibly stale) olds.name (re-review)', () => {
107
+ test('a row renamed out of band is still found and deleted, by id', async () => {
108
+ const fake = fakePaperless();
109
+ const created = await run(ops.reconcile({ name: 'Invoices' }, undefined), fake.fetch);
110
+ if (created === undefined) throw new Error('unreachable: reconcile always returns attrs');
111
+
112
+ await run(tags.updateTagsPartial({ id: created.id, name: 'Renamed Elsewhere' }), fake.fetch);
113
+ await run(ops.destroy({ name: 'Invoices' }, created), fake.fetch);
114
+
115
+ expect(fake.rows('tags').length).toBe(0);
116
+ const deletes = fake.seen.filter((s) => s.method === 'DELETE');
117
+ expect(deletes.length).toBe(1);
118
+ expect(deletes[0]?.path).toBe(`/api/tags/${String(created.id)}/`);
119
+ });
120
+
121
+ test('with no output (no-state path), delete still falls back to locating by name', async () => {
122
+ const fake = fakePaperless();
123
+ await run(ops.reconcile({ name: 'Invoices' }, undefined), fake.fetch);
124
+ await run(ops.destroy({ name: 'Invoices' }, undefined), fake.fetch);
125
+ expect(fake.rows('tags').length).toBe(0);
126
+ });
127
+ });