@homeflare/alchemy 0.23.0 → 0.25.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 (177) hide show
  1. package/README.md +25 -1
  2. package/dist/litellm/client.d.ts +76 -0
  3. package/dist/litellm/client.d.ts.map +1 -0
  4. package/dist/litellm/credentials.d.ts +48 -0
  5. package/dist/litellm/credentials.d.ts.map +1 -0
  6. package/dist/litellm/fake-litellm.d.ts +36 -0
  7. package/dist/litellm/fake-litellm.d.ts.map +1 -0
  8. package/dist/litellm/fake-stack.d.ts +30 -0
  9. package/dist/litellm/fake-stack.d.ts.map +1 -0
  10. package/dist/litellm/generated/pass-through.d.ts +71 -0
  11. package/dist/litellm/generated/pass-through.d.ts.map +1 -0
  12. package/dist/litellm/index.d.ts +13 -0
  13. package/dist/litellm/index.d.ts.map +1 -0
  14. package/dist/litellm/index.js +270 -0
  15. package/dist/litellm/index.js.map +14 -0
  16. package/dist/litellm/pass-through-endpoint.d.ts +94 -0
  17. package/dist/litellm/pass-through-endpoint.d.ts.map +1 -0
  18. package/dist/litellm/pass-through-form.d.ts +40 -0
  19. package/dist/litellm/pass-through-form.d.ts.map +1 -0
  20. package/dist/litellm/providers.d.ts +16 -0
  21. package/dist/litellm/providers.d.ts.map +1 -0
  22. package/dist/paperless/client.d.ts +37 -0
  23. package/dist/paperless/client.d.ts.map +1 -0
  24. package/dist/paperless/constraint-guard.d.ts +19 -0
  25. package/dist/paperless/constraint-guard.d.ts.map +1 -0
  26. package/dist/paperless/constraints.d.ts +50 -0
  27. package/dist/paperless/constraints.d.ts.map +1 -0
  28. package/dist/paperless/credentials.d.ts +40 -0
  29. package/dist/paperless/credentials.d.ts.map +1 -0
  30. package/dist/paperless/custom-field.d.ts +75 -0
  31. package/dist/paperless/custom-field.d.ts.map +1 -0
  32. package/dist/paperless/document-type.d.ts +30 -0
  33. package/dist/paperless/document-type.d.ts.map +1 -0
  34. package/dist/paperless/errors.d.ts +49 -0
  35. package/dist/paperless/errors.d.ts.map +1 -0
  36. package/dist/paperless/fake-paperless.d.ts +39 -0
  37. package/dist/paperless/fake-paperless.d.ts.map +1 -0
  38. package/dist/paperless/generated/constraints/index.d.ts +13 -0
  39. package/dist/paperless/generated/constraints/index.d.ts.map +1 -0
  40. package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts +21 -0
  41. package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts.map +1 -0
  42. package/dist/paperless/generated/constraints/paperless-document_types.d.ts +21 -0
  43. package/dist/paperless/generated/constraints/paperless-document_types.d.ts.map +1 -0
  44. package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts +21 -0
  45. package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts.map +1 -0
  46. package/dist/paperless/generated/constraints/paperless-tags.d.ts +21 -0
  47. package/dist/paperless/generated/constraints/paperless-tags.d.ts.map +1 -0
  48. package/dist/paperless/generated/types/custom-fields.d.ts +23 -0
  49. package/dist/paperless/generated/types/custom-fields.d.ts.map +1 -0
  50. package/dist/paperless/generated/types/document-types.d.ts +39 -0
  51. package/dist/paperless/generated/types/document-types.d.ts.map +1 -0
  52. package/dist/paperless/generated/types/index.d.ts +10 -0
  53. package/dist/paperless/generated/types/index.d.ts.map +1 -0
  54. package/dist/paperless/generated/types/storage-paths.d.ts +31 -0
  55. package/dist/paperless/generated/types/storage-paths.d.ts.map +1 -0
  56. package/dist/paperless/generated/types/tags.d.ts +37 -0
  57. package/dist/paperless/generated/types/tags.d.ts.map +1 -0
  58. package/dist/paperless/index.d.ts +19 -0
  59. package/dist/paperless/index.d.ts.map +1 -0
  60. package/dist/paperless/index.js +524 -0
  61. package/dist/paperless/index.js.map +26 -0
  62. package/dist/paperless/matching-locate.d.ts +33 -0
  63. package/dist/paperless/matching-locate.d.ts.map +1 -0
  64. package/dist/paperless/matching-types.d.ts +60 -0
  65. package/dist/paperless/matching-types.d.ts.map +1 -0
  66. package/dist/paperless/matching.d.ts +61 -0
  67. package/dist/paperless/matching.d.ts.map +1 -0
  68. package/dist/paperless/providers.d.ts +15 -0
  69. package/dist/paperless/providers.d.ts.map +1 -0
  70. package/dist/paperless/storage-path.d.ts +32 -0
  71. package/dist/paperless/storage-path.d.ts.map +1 -0
  72. package/dist/paperless/tag.d.ts +47 -0
  73. package/dist/paperless/tag.d.ts.map +1 -0
  74. package/dist/postgres/connection.d.ts +50 -0
  75. package/dist/postgres/connection.d.ts.map +1 -0
  76. package/dist/postgres/database-attrs.d.ts +79 -0
  77. package/dist/postgres/database-attrs.d.ts.map +1 -0
  78. package/dist/postgres/database-sql.d.ts +73 -0
  79. package/dist/postgres/database-sql.d.ts.map +1 -0
  80. package/dist/postgres/database.d.ts +63 -0
  81. package/dist/postgres/database.d.ts.map +1 -0
  82. package/dist/postgres/errors.d.ts +86 -0
  83. package/dist/postgres/errors.d.ts.map +1 -0
  84. package/dist/postgres/fake-sql.d.ts +22 -0
  85. package/dist/postgres/fake-sql.d.ts.map +1 -0
  86. package/dist/postgres/index.d.ts +16 -0
  87. package/dist/postgres/index.d.ts.map +1 -0
  88. package/dist/postgres/index.js +238 -0
  89. package/dist/postgres/index.js.map +15 -0
  90. package/dist/postgres/providers.d.ts +14 -0
  91. package/dist/postgres/providers.d.ts.map +1 -0
  92. package/dist/proxmox/index.js.map +2 -2
  93. package/dist/proxmox/zfs-pool.d.ts +10 -2
  94. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  95. package/dist/release/archive.d.ts +6 -2
  96. package/dist/release/archive.d.ts.map +1 -1
  97. package/dist/release/binary-form.d.ts +14 -9
  98. package/dist/release/binary-form.d.ts.map +1 -1
  99. package/dist/release/binary-pins.d.ts +17 -0
  100. package/dist/release/binary-pins.d.ts.map +1 -0
  101. package/dist/release/catalog.d.ts.map +1 -1
  102. package/dist/release/index.js +90 -40
  103. package/dist/release/index.js.map +8 -7
  104. package/dist/release/tar.d.ts +9 -2
  105. package/dist/release/tar.d.ts.map +1 -1
  106. package/docs/litellm.md +110 -0
  107. package/docs/paperless.md +115 -0
  108. package/docs/peers.md +8 -1
  109. package/docs/postgres.md +125 -0
  110. package/docs/release-binary-catalogs.md +40 -4
  111. package/docs/release-binary-refusals.md +22 -8
  112. package/docs/release-binary-upstream.md +33 -15
  113. package/package.json +14 -1
  114. package/src/litellm/client.ts +195 -0
  115. package/src/litellm/credentials.ts +73 -0
  116. package/src/litellm/fake-litellm.ts +114 -0
  117. package/src/litellm/fake-stack.ts +100 -0
  118. package/src/litellm/generated/pass-through.ts +73 -0
  119. package/src/litellm/index.ts +42 -0
  120. package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +66 -0
  121. package/src/litellm/pass-through-endpoint-replace.test.ts +84 -0
  122. package/src/litellm/pass-through-endpoint.test.ts +208 -0
  123. package/src/litellm/pass-through-endpoint.ts +258 -0
  124. package/src/litellm/pass-through-form.test.ts +128 -0
  125. package/src/litellm/pass-through-form.ts +136 -0
  126. package/src/litellm/providers.ts +19 -0
  127. package/src/paperless/client.ts +88 -0
  128. package/src/paperless/constraint-guard.ts +50 -0
  129. package/src/paperless/constraints.test.ts +74 -0
  130. package/src/paperless/constraints.ts +111 -0
  131. package/src/paperless/credentials.ts +69 -0
  132. package/src/paperless/custom-field.test.ts +74 -0
  133. package/src/paperless/custom-field.ts +95 -0
  134. package/src/paperless/document-type.ts +74 -0
  135. package/src/paperless/errors.ts +62 -0
  136. package/src/paperless/fake-paperless.ts +156 -0
  137. package/src/paperless/generated/constraints/index.ts +23 -0
  138. package/src/paperless/generated/constraints/paperless-custom_fields.ts +29 -0
  139. package/src/paperless/generated/constraints/paperless-document_types.ts +32 -0
  140. package/src/paperless/generated/constraints/paperless-storage_paths.ts +34 -0
  141. package/src/paperless/generated/constraints/paperless-tags.ts +34 -0
  142. package/src/paperless/generated/types/custom-fields.ts +23 -0
  143. package/src/paperless/generated/types/document-types.ts +30 -0
  144. package/src/paperless/generated/types/index.ts +9 -0
  145. package/src/paperless/generated/types/storage-paths.ts +31 -0
  146. package/src/paperless/generated/types/tags.ts +37 -0
  147. package/src/paperless/index.ts +56 -0
  148. package/src/paperless/matching-identity.test.ts +194 -0
  149. package/src/paperless/matching-locate.ts +104 -0
  150. package/src/paperless/matching-types.ts +61 -0
  151. package/src/paperless/matching.test.ts +128 -0
  152. package/src/paperless/matching.ts +239 -0
  153. package/src/paperless/providers.ts +27 -0
  154. package/src/paperless/storage-path.ts +78 -0
  155. package/src/paperless/tag.ts +91 -0
  156. package/src/postgres/connection.ts +62 -0
  157. package/src/postgres/database-attrs.ts +87 -0
  158. package/src/postgres/database-bigint-serialization.test.ts +140 -0
  159. package/src/postgres/database-lifecycle.test.ts +113 -0
  160. package/src/postgres/database-provenance.test.ts +118 -0
  161. package/src/postgres/database-refusals.test.ts +135 -0
  162. package/src/postgres/database-sql.ts +174 -0
  163. package/src/postgres/database.ts +193 -0
  164. package/src/postgres/errors.ts +131 -0
  165. package/src/postgres/fake-sql.ts +123 -0
  166. package/src/postgres/fixtures/pg-REL_18_6-create-database-synopsis.txt +26 -0
  167. package/src/postgres/fixtures/pg-REL_18_6-pg_database.h.txt +67 -0
  168. package/src/postgres/index.ts +30 -0
  169. package/src/postgres/providers.ts +16 -0
  170. package/src/proxmox/zfs-pool.ts +10 -2
  171. package/src/release/archive.ts +10 -4
  172. package/src/release/binary-form.ts +24 -76
  173. package/src/release/binary-pins.ts +95 -0
  174. package/src/release/binary-root.test.ts +122 -0
  175. package/src/release/catalog.ts +10 -2
  176. package/src/release/tar-root.test.ts +114 -0
  177. package/src/release/tar.ts +78 -12
@@ -0,0 +1,239 @@
1
+ /**
2
+ * One shape for every Paperless taxonomy object (Tag, DocumentType, StoragePath, CustomField):
3
+ * locate by `name__iexact` only while there is no prior state, identify the exact (name, owner),
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.
6
+ *
7
+ * ⛔ THE CREATE BODY ALWAYS CARRIES `owner`, NULL WHEN UNDECLARED. `OwnedObjectSerializer.create`
8
+ * sets the owner to the TOKEN USER when the field is absent from the body (Paperless-ngx
9
+ * `documents/serialisers.py:495-502` at v3.1.1) — omitting it the way NetBox's `prefix-form.ts`
10
+ * omits an undeclared foreign key would silently create an object owned by whichever credential
11
+ * ran the deploy, invisible to every other user and a second row beside the estate's `owner:
12
+ * null` one on the next apply. Sending `owner: null` explicitly is what keeps a create
13
+ * idempotent under this vendor's default.
14
+ *
15
+ * ★ READ ANSWERS `Unowned(attrs)` ON EVERY MATCH (H1, alchemy-provider-standard). Paperless has no
16
+ * ownership marker this package could check — `owner` here is the estate's OWN prop, not proof
17
+ * 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`).
20
+ *
21
+ * ⛔ 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`.
47
+ */
48
+ import { Unowned } from 'alchemy/AdoptPolicy';
49
+ import { isResolved } from 'alchemy/Diff';
50
+ import type { Input } from 'alchemy/Input';
51
+ import * as Effect from 'effect/Effect';
52
+ import type { PaperlessRow } from './client.ts';
53
+ import { paperless } from './client.ts';
54
+ import { guardBody } from './constraint-guard.ts';
55
+ import type { PaperlessBody } from './constraints.ts';
56
+ import { absentOn404, matchingLocate, ownerOf } from './matching-locate.ts';
57
+ import type { MatchingProps, MatchingSpec } from './matching-types.ts';
58
+
59
+ /** 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
+ export type {
61
+ FieldSpec,
62
+ MatchingError,
63
+ MatchingProps,
64
+ MatchingRequirements,
65
+ MatchingSpec,
66
+ } from './matching-types.ts';
67
+
68
+ /** Object equality good enough for this family's scalars, nullable ints and `extra_data` JSON. */
69
+ const sameValue = (a: unknown, b: unknown): boolean =>
70
+ typeof a === 'object' && a !== null ? JSON.stringify(a) === JSON.stringify(b) : Object.is(a, b);
71
+
72
+ export const matchingOperations = <
73
+ Props extends MatchingProps,
74
+ Attributes extends { readonly id: number },
75
+ >(
76
+ spec: MatchingSpec<Props, Attributes>,
77
+ ) => {
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);
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. */
83
+ const read = (props: Props) =>
84
+ fetchByName(props).pipe(
85
+ Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
86
+ );
87
+
88
+ const readById = (props: Props, id: number) =>
89
+ fetchById(id).pipe(
90
+ Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
91
+ );
92
+
93
+ const createBody = (props: Props): PaperlessBody => {
94
+ const out: PaperlessBody =
95
+ spec.owned === false
96
+ ? { name: props.name }
97
+ : { name: props.name, owner: props.owner ?? null };
98
+ for (const field of [...spec.fields, ...(spec.createOnly ?? [])]) {
99
+ const value = field.prop(props);
100
+ if (value !== undefined) out[field.key] = value;
101
+ }
102
+ return out;
103
+ };
104
+
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 => {
114
+ const out: PaperlessBody = {};
115
+ if (props.name !== live['name']) out['name'] = props.name;
116
+ if (spec.owned !== false && ownerOf(live) !== (props.owner ?? undefined)) {
117
+ out['owner'] = props.owner ?? null;
118
+ }
119
+ for (const field of spec.fields) {
120
+ const value = field.prop(props);
121
+ if (value !== undefined && !sameValue(value, field.live(live))) out[field.key] = value;
122
+ }
123
+ return out;
124
+ };
125
+
126
+ const matches = (props: Props, live: PaperlessRow): boolean => {
127
+ if (props.name !== live['name']) return false;
128
+ if (spec.owned !== false && ownerOf(live) !== (props.owner ?? undefined)) return false;
129
+ return spec.fields.every((field) => {
130
+ const value = field.prop(props);
131
+ return value === undefined || sameValue(value, field.live(live));
132
+ });
133
+ };
134
+
135
+ return {
136
+ read,
137
+
138
+ diff: (news: Input<Props>, output: Attributes | undefined) =>
139
+ Effect.gen(function* () {
140
+ if (output === undefined || !isResolved(news)) return undefined;
141
+ const live = yield* fetchLive(news, output);
142
+ if (live === undefined) return { action: 'update' } as const;
143
+ const refusal = spec.immutable?.(live, news);
144
+ if (refusal !== undefined) return yield* Effect.die(new Error(refusal));
145
+ if (matches(news, live)) return { action: 'noop' } as const;
146
+ return spec.endpoint.update === undefined
147
+ ? ({ action: 'replace' } as const)
148
+ : ({ action: 'update' } as const);
149
+ }),
150
+
151
+ reconcile: (news: Props, output: Attributes | undefined) =>
152
+ Effect.gen(function* () {
153
+ const before = yield* fetchLive(news, output);
154
+ let id: number | undefined;
155
+ if (before === undefined) {
156
+ const body = createBody(news);
157
+ yield* guardBody(spec.endpoint.create, body, true);
158
+ yield* paperless('POST', `${spec.collection}/`, body);
159
+ } 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.
163
+ const refusal = spec.immutable?.(before, news);
164
+ if (refusal !== undefined) return yield* Effect.die(new Error(refusal));
165
+ if (spec.endpoint.update !== undefined) {
166
+ const body = patchBody(news, before);
167
+ if (Object.keys(body).length > 0) {
168
+ yield* guardBody(spec.endpoint.update, body, false);
169
+ yield* paperless('PATCH', `${objectPath(before)}/`, body);
170
+ }
171
+ }
172
+ }
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
+ const after = yield* id === undefined ? read(news) : readById(news, id);
178
+ if (after === undefined) {
179
+ return yield* Effect.die(
180
+ new Error(
181
+ `${spec.describe(news)}: the write returned no error but the object is still absent.`,
182
+ ),
183
+ );
184
+ }
185
+ return after;
186
+ }),
187
+
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
+ */
201
+ destroy: (olds: Props, output: Attributes | undefined) =>
202
+ Effect.gen(function* () {
203
+ const live = output === undefined ? yield* fetchByName(olds) : yield* fetchById(output.id);
204
+ if (live === undefined) return;
205
+ yield* absentOn404(paperless('DELETE', `${objectPath(live)}/`));
206
+ }),
207
+ };
208
+ };
209
+
210
+ /** The five provider handlers for a spec'd Paperless taxonomy object, wired once. */
211
+ export const matchingHandlers = <
212
+ Props extends MatchingProps,
213
+ Attributes extends { readonly id: number },
214
+ >(
215
+ spec: MatchingSpec<Props, Attributes>,
216
+ ) => {
217
+ const ops = matchingOperations(spec);
218
+ return {
219
+ /** ⚠️ `[]` — Paperless index endpoints return the whole estate; adoption stays explicit. */
220
+ list: () => Effect.succeed([]),
221
+ read: ({ olds }: { olds: Props }) =>
222
+ ops
223
+ .read(olds)
224
+ .pipe(Effect.map((attrs) => (attrs === undefined ? undefined : Unowned(attrs)))),
225
+ 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.
232
+ 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.
236
+ delete: ({ olds, output }: { olds: Props; output?: Attributes | undefined }) =>
237
+ ops.destroy(olds, output),
238
+ };
239
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * All four Paperless taxonomy providers, one credentials layer shared between them.
3
+ *
4
+ * Layer.mergeAll(paperlessProviders(), …the stack's other providers)
5
+ *
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.
11
+ */
12
+ import * as Layer from 'effect/Layer';
13
+ import { CustomFieldProvider } from './custom-field.ts';
14
+ import { DocumentTypeProvider } from './document-type.ts';
15
+ import { type PaperlessCredentials, environmentLayer } from './credentials.ts';
16
+ import { StoragePathProvider } from './storage-path.ts';
17
+ import { TagProvider } from './tag.ts';
18
+
19
+ export const paperlessProviders = (
20
+ credentials: Layer.Layer<PaperlessCredentials> = environmentLayer,
21
+ ) =>
22
+ Layer.mergeAll(
23
+ TagProvider(),
24
+ DocumentTypeProvider(),
25
+ StoragePathProvider(),
26
+ CustomFieldProvider(),
27
+ ).pipe(Layer.provide(credentials));
@@ -0,0 +1,78 @@
1
+ /**
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.
4
+ */
5
+ import { Resource } from 'alchemy';
6
+ import * as Provider from 'alchemy/Provider';
7
+ import * as Effect from 'effect/Effect';
8
+ import type { PaperlessStoragePathRequest } from './generated/types/storage-paths.ts';
9
+ import { type MatchingProps, type MatchingRequirements, matchingHandlers } from './matching.ts';
10
+
11
+ export interface StoragePathProps extends MatchingProps {
12
+ /** ⚠️ maxLength 128 (generated constraint table). The locate key. */
13
+ name: string;
14
+ /** ⚠️ minLength 1 (generated table). Paperless's storage-path template syntax, e.g. `{correspondent}/{title}`. */
15
+ path: string;
16
+ /** ⚠️ maxLength 256. */
17
+ match?: string;
18
+ matchingAlgorithm?: PaperlessStoragePathRequest['matching_algorithm'];
19
+ isInsensitive?: boolean;
20
+ }
21
+
22
+ export interface StoragePathAttributes {
23
+ id: number;
24
+ name: string;
25
+ path: string;
26
+ match: string;
27
+ matchingAlgorithm: number;
28
+ isInsensitive: boolean;
29
+ owner: number | undefined;
30
+ }
31
+
32
+ export interface StoragePath extends Resource<
33
+ 'Paperless.StoragePath',
34
+ StoragePathProps,
35
+ StoragePathAttributes,
36
+ never,
37
+ MatchingRequirements
38
+ > {}
39
+
40
+ export const StoragePath = Resource<StoragePath>('Paperless.StoragePath', {
41
+ defaultRemovalPolicy: 'retain',
42
+ });
43
+
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',
60
+ describe: (props) => `storage_paths ${props.name}`,
61
+ endpoint: {
62
+ create: 'paperless:POST /api/storage_paths/',
63
+ update: 'paperless:PATCH /api/storage_paths/{id}/',
64
+ },
65
+ fields: [
66
+ { key: 'path', live: (r) => r['path'], prop: (p) => p.path },
67
+ { key: 'match', live: (r) => r['match'], prop: (p) => p.match },
68
+ {
69
+ key: 'matching_algorithm',
70
+ live: (r) => r['matching_algorithm'],
71
+ prop: (p) => p.matchingAlgorithm,
72
+ },
73
+ { key: 'is_insensitive', live: (r) => r['is_insensitive'], prop: (p) => p.isInsensitive },
74
+ ],
75
+ });
76
+
77
+ export const StoragePathProvider = () =>
78
+ Provider.effect(StoragePath, Effect.succeed(StoragePath.Provider.of(handlers)));
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `Paperless.Tag` — a Paperless-ngx tag, create-or-update and never deleted by default.
3
+ *
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.
9
+ *
10
+ * ⛔ NO CREDENTIAL IS A PROP. `PAPERLESS_URL`/`PAPERLESS_TOKEN` come from `credentials.ts`.
11
+ * ⛔ `defaultRemovalPolicy: 'retain'` — the reconciler this replaces never deletes taxonomy, and
12
+ * a tag's id is referenced by every document it is attached to.
13
+ */
14
+ import { Resource } from 'alchemy';
15
+ import * as Provider from 'alchemy/Provider';
16
+ import * as Effect from 'effect/Effect';
17
+ import type { PaperlessTagRequest } from './generated/types/tags.ts';
18
+ import { type MatchingProps, type MatchingRequirements, matchingHandlers } from './matching.ts';
19
+
20
+ export interface TagProps extends MatchingProps {
21
+ /** ⚠️ maxLength 128 (generated constraint table). The locate key — see matching.ts's header. */
22
+ name: string;
23
+ /** ⚠️ minLength 1, maxLength 7 (a CSS hex color, generated table doesn't enforce the `#`). */
24
+ color?: string;
25
+ /** ⚠️ maxLength 256. The text or pattern documents are matched against. */
26
+ match?: string;
27
+ matchingAlgorithm?: PaperlessTagRequest['matching_algorithm'];
28
+ isInsensitive?: boolean;
29
+ /** Marks this tag as an inbox tag: newly consumed documents are tagged with it. */
30
+ isInboxTag?: boolean;
31
+ /** Another `Paperless.Tag`'s id — Paperless allows one level of tag nesting. */
32
+ parent?: number;
33
+ }
34
+
35
+ export interface TagAttributes {
36
+ id: number;
37
+ name: string;
38
+ color: string;
39
+ match: string;
40
+ matchingAlgorithm: number;
41
+ isInsensitive: boolean;
42
+ isInboxTag: boolean;
43
+ owner: number | undefined;
44
+ parent: number | undefined;
45
+ }
46
+
47
+ export interface Tag extends Resource<
48
+ 'Paperless.Tag',
49
+ TagProps,
50
+ TagAttributes,
51
+ never,
52
+ MatchingRequirements
53
+ > {}
54
+
55
+ export const Tag = Resource<Tag>('Paperless.Tag', { defaultRemovalPolicy: 'retain' });
56
+
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',
75
+ describe: (props) => `tags ${props.name}`,
76
+ endpoint: { create: 'paperless:POST /api/tags/', update: 'paperless:PATCH /api/tags/{id}/' },
77
+ fields: [
78
+ { key: 'color', live: (r) => r['color'], prop: (p) => p.color },
79
+ { key: 'match', live: (r) => r['match'], prop: (p) => p.match },
80
+ {
81
+ key: 'matching_algorithm',
82
+ live: (r) => r['matching_algorithm'],
83
+ prop: (p) => p.matchingAlgorithm,
84
+ },
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 },
88
+ ],
89
+ });
90
+
91
+ export const TagProvider = () => Provider.effect(Tag, Effect.succeed(Tag.Provider.of(handlers)));
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Where a database's cluster comes from, and how each lifecycle operation gets a client.
3
+ *
4
+ * ⛔ NO ESTATE VALUE LIVES HERE. The kit is PUBLIC; the mini's socket path, its user and its
5
+ * database names are the CONSUMER's props, passed to `postgresConnection(...)` in a house
6
+ * stack — never a default, never a fallback (`docs/postgres.md#path`).
7
+ * ★ A LAZY `Context.Service` WHOSE VALUE IS AN `Effect` (S24), THE SAME SHAPE THE HOUSE USES FOR
8
+ * CREDENTIALS: `yield* yield* PostgresConnection` resolves the config INSIDE each operation,
9
+ * never at layer build. Nothing here holds a secret today (`PostgresConnectionConfig` has no
10
+ * `password` — S25, and CREATE DATABASE has none to assert either), but the shape is the one a
11
+ * later TLS path (`Config.redacted` from the environment) drops into without a rewrite.
12
+ * ★ THE POOL IS BUILT IN A SCOPE AND CLOSED PER OPERATION. `Effect.provide(op, PgClient.layer(config))`
13
+ * already opens a scope for the layer's build and closes it when `op` finishes — one pool per
14
+ * `withPg` call, never held across a whole `reconcile`.
15
+ */
16
+ import * as PgClient from '@effect/sql-pg/PgClient';
17
+ import * as Context from 'effect/Context';
18
+ import * as Effect from 'effect/Effect';
19
+ import * as Layer from 'effect/Layer';
20
+ import type { SqlError } from 'effect/unstable/sql/SqlError';
21
+
22
+ /** What a stack passes to reach one cluster. `host` may be a socket directory (one beginning
23
+ * with `/` expands to `${host}/.s.PGSQL.${port}` — measured in
24
+ * `node_modules/@effect/sql-pg/src/PgConnection.ts`, the "Details" doc on `Config`) or a TCP
25
+ * host name; `port` defaults to `@effect/sql-pg`'s own default (5432) when omitted. */
26
+ export interface PostgresConnectionConfig {
27
+ readonly host: string;
28
+ readonly port?: number;
29
+ readonly database: string;
30
+ readonly username: string;
31
+ readonly ssl?: boolean;
32
+ }
33
+
34
+ /** The lazy connection service. Its value is an `Effect` of the config, per S24 — see the file
35
+ * header for why. */
36
+ export class PostgresConnection extends Context.Service<
37
+ PostgresConnection,
38
+ Effect.Effect<PostgresConnectionConfig>
39
+ >()('Postgres.Connection') {}
40
+
41
+ /** A stack's one-line way to provide a cluster: `Layer.provide(postgresConnection({ … }))` on
42
+ * `PostgresDatabaseProvider()`, or merged into the stack's own layer tree. */
43
+ export const postgresConnection = (
44
+ config: PostgresConnectionConfig,
45
+ ): Layer.Layer<PostgresConnection> => Layer.succeed(PostgresConnection, Effect.succeed(config));
46
+
47
+ /**
48
+ * Run one operation against a freshly built, freshly closed connection pool.
49
+ *
50
+ * Every `read`, `reconcile` step and `delete` in this family calls this once for the whole
51
+ * operation — never once per statement — so a reconcile that runs a `SELECT`, then a `CREATE`,
52
+ * then a re-read shares one pool and closes it once, while a plan-only `read` still opens and
53
+ * closes its own.
54
+ */
55
+ export const withPg = <A, E>(
56
+ build: (pg: PgClient.PgClient) => Effect.Effect<A, E>,
57
+ ): Effect.Effect<A, E | SqlError, PostgresConnection> =>
58
+ Effect.gen(function* () {
59
+ const resolveConfig = yield* PostgresConnection;
60
+ const config = yield* resolveConfig;
61
+ return yield* Effect.provide(Effect.flatMap(PgClient.PgClient, build), PgClient.layer(config));
62
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `Postgres.Database` props and attributes, and the one prop-level check that runs before any
3
+ * statement: the name's byte length against `NAMEDATALEN`.
4
+ *
5
+ * ⛔ EVERY ASSERTED PROP HERE HAS A CITED COLUMN. `docs/postgres.md#mapping` and
6
+ * `database-provenance.test.ts` are what make that true rather than asserted: the test parses
7
+ * the two committed fixtures (`create_database.sgml`'s synopsis, `pg_database.h`'s struct) and
8
+ * fails if a prop here has no synopsis option or no catalog column behind it.
9
+ * ⛔ NO PASSWORD, EVER. A database has none in `pg_database` (S25) — `CREATE DATABASE` has no
10
+ * password option at all (`gram.y@REL_18_6#createdb_opt_name`, measured: `IDENT | CONNECTION
11
+ * LIMIT | ENCODING | LOCATION | OWNER | TABLESPACE | TEMPLATE`).
12
+ */
13
+
14
+ /** NAMEDATALEN (`pg_config_manual.h@REL_18_6` line 29) minus the C string terminator: the
15
+ * longest name the server stores without truncating. */
16
+ export const POSTGRES_NAME_MAX_BYTES = 63;
17
+
18
+ /** UTF-8 byte length, the same unit `NAMEDATALEN` counts in (`scansup.c@REL_18_6#truncate_identifier`
19
+ * clips by `pg_mbcliplen`, a byte-oriented, multibyte-aware clip — never a JS `.length`, which
20
+ * counts UTF-16 code units and would pass a 63-byte multibyte name that is really 70+ bytes, or
21
+ * refuse a 63-byte name whose `.length` looks smaller). */
22
+ export const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength;
23
+
24
+ /**
25
+ * Declared, at-most-once-asserted properties of one database.
26
+ *
27
+ * Every prop here maps to a `CREATE DATABASE` `WITH` option `create_database.sgml` documents
28
+ * AND a `pg_database` column that stores it (`database-provenance.test.ts`). `TEMPLATE`,
29
+ * `STRATEGY` and `OID` are excluded because nothing in `pg_database` reads them back once the
30
+ * database exists; `ICU_RULES` and `COLLATION_VERSION` are excluded for having no consumer in
31
+ * this family today. See `docs/postgres.md#excluded`.
32
+ */
33
+ export interface PostgresDatabaseProps {
34
+ /** At most 63 UTF-8 bytes — refused at plan past that (`PostgresDatabaseNameRefused`). The
35
+ * identity: a name change is a plan-time refusal, never a replace. */
36
+ readonly name: string;
37
+ /** A role name in `pg_roles`, asserted before create (`PostgresDatabaseOwnerMissing`) and
38
+ * compared against `pg_get_userbyid(datdba)` on every plan thereafter. */
39
+ readonly owner: string;
40
+ /** `pg_encoding_to_char(datdatabase.encoding)`, e.g. `"UTF8"`. @default the template's. */
41
+ readonly encoding?: string;
42
+ /** `'builtin' | 'icu' | 'libc'` — `pg_database.datlocprovider` decoded
43
+ * (`pg_collation.h@REL_18_6#collprovider_name`). @default the template's. */
44
+ readonly localeProvider?: 'builtin' | 'icu' | 'libc';
45
+ /** `pg_database.datcollate`. @default the template's. */
46
+ readonly lcCollate?: string;
47
+ /** `pg_database.datctype`. @default the template's. */
48
+ readonly lcCtype?: string;
49
+ /** `pg_database.datallowconn`. @default `true`. */
50
+ readonly allowConnections?: boolean;
51
+ /** `pg_database.datconnlimit`; `-1` means unlimited. @default `-1`. */
52
+ readonly connectionLimit?: number;
53
+ /** `pg_database.datistemplate`. @default `false`. */
54
+ readonly isTemplate?: boolean;
55
+ /** A tablespace name in `pg_tablespace`. @default the template's (usually `pg_default`). */
56
+ readonly tablespace?: string;
57
+ }
58
+
59
+ /** Live attributes, read back after every reconcile. No `password` — see the file header. */
60
+ export interface PostgresDatabaseAttributes {
61
+ readonly name: string;
62
+ readonly oid: number;
63
+ readonly owner: string;
64
+ readonly encoding: string;
65
+ readonly localeProvider: 'builtin' | 'icu' | 'libc';
66
+ readonly collate: string;
67
+ readonly ctype: string;
68
+ readonly allowConnections: boolean;
69
+ readonly connectionLimit: number;
70
+ readonly isTemplate: boolean;
71
+ readonly tablespace: string;
72
+ }
73
+
74
+ /**
75
+ * Refuse a name the server would truncate, before any statement is built.
76
+ *
77
+ * ★ CALLED FROM `diff` (so a plan fails, never a deploy) AND FROM `reconcile` (so a fake or a
78
+ * direct call gets the same refusal, never just a broken statement).
79
+ */
80
+ export const nameByteRefusal = (
81
+ name: string,
82
+ ): { readonly byteLength: number; readonly limit: number } | undefined => {
83
+ const byteLength = utf8ByteLength(name);
84
+ return byteLength > POSTGRES_NAME_MAX_BYTES
85
+ ? { byteLength, limit: POSTGRES_NAME_MAX_BYTES }
86
+ : undefined;
87
+ };