@homeflare/alchemy 0.24.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 (84) hide show
  1. package/README.md +10 -0
  2. package/dist/paperless/client.d.ts +37 -0
  3. package/dist/paperless/client.d.ts.map +1 -0
  4. package/dist/paperless/constraint-guard.d.ts +19 -0
  5. package/dist/paperless/constraint-guard.d.ts.map +1 -0
  6. package/dist/paperless/constraints.d.ts +50 -0
  7. package/dist/paperless/constraints.d.ts.map +1 -0
  8. package/dist/paperless/credentials.d.ts +40 -0
  9. package/dist/paperless/credentials.d.ts.map +1 -0
  10. package/dist/paperless/custom-field.d.ts +75 -0
  11. package/dist/paperless/custom-field.d.ts.map +1 -0
  12. package/dist/paperless/document-type.d.ts +30 -0
  13. package/dist/paperless/document-type.d.ts.map +1 -0
  14. package/dist/paperless/errors.d.ts +49 -0
  15. package/dist/paperless/errors.d.ts.map +1 -0
  16. package/dist/paperless/fake-paperless.d.ts +39 -0
  17. package/dist/paperless/fake-paperless.d.ts.map +1 -0
  18. package/dist/paperless/generated/constraints/index.d.ts +13 -0
  19. package/dist/paperless/generated/constraints/index.d.ts.map +1 -0
  20. package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts +21 -0
  21. package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts.map +1 -0
  22. package/dist/paperless/generated/constraints/paperless-document_types.d.ts +21 -0
  23. package/dist/paperless/generated/constraints/paperless-document_types.d.ts.map +1 -0
  24. package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts +21 -0
  25. package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts.map +1 -0
  26. package/dist/paperless/generated/constraints/paperless-tags.d.ts +21 -0
  27. package/dist/paperless/generated/constraints/paperless-tags.d.ts.map +1 -0
  28. package/dist/paperless/generated/types/custom-fields.d.ts +23 -0
  29. package/dist/paperless/generated/types/custom-fields.d.ts.map +1 -0
  30. package/dist/paperless/generated/types/document-types.d.ts +39 -0
  31. package/dist/paperless/generated/types/document-types.d.ts.map +1 -0
  32. package/dist/paperless/generated/types/index.d.ts +10 -0
  33. package/dist/paperless/generated/types/index.d.ts.map +1 -0
  34. package/dist/paperless/generated/types/storage-paths.d.ts +31 -0
  35. package/dist/paperless/generated/types/storage-paths.d.ts.map +1 -0
  36. package/dist/paperless/generated/types/tags.d.ts +37 -0
  37. package/dist/paperless/generated/types/tags.d.ts.map +1 -0
  38. package/dist/paperless/index.d.ts +19 -0
  39. package/dist/paperless/index.d.ts.map +1 -0
  40. package/dist/paperless/index.js +524 -0
  41. package/dist/paperless/index.js.map +26 -0
  42. package/dist/paperless/matching-locate.d.ts +33 -0
  43. package/dist/paperless/matching-locate.d.ts.map +1 -0
  44. package/dist/paperless/matching-types.d.ts +60 -0
  45. package/dist/paperless/matching-types.d.ts.map +1 -0
  46. package/dist/paperless/matching.d.ts +61 -0
  47. package/dist/paperless/matching.d.ts.map +1 -0
  48. package/dist/paperless/providers.d.ts +15 -0
  49. package/dist/paperless/providers.d.ts.map +1 -0
  50. package/dist/paperless/storage-path.d.ts +32 -0
  51. package/dist/paperless/storage-path.d.ts.map +1 -0
  52. package/dist/paperless/tag.d.ts +47 -0
  53. package/dist/paperless/tag.d.ts.map +1 -0
  54. package/docs/paperless.md +115 -0
  55. package/package.json +5 -1
  56. package/src/paperless/client.ts +88 -0
  57. package/src/paperless/constraint-guard.ts +50 -0
  58. package/src/paperless/constraints.test.ts +74 -0
  59. package/src/paperless/constraints.ts +111 -0
  60. package/src/paperless/credentials.ts +69 -0
  61. package/src/paperless/custom-field.test.ts +74 -0
  62. package/src/paperless/custom-field.ts +95 -0
  63. package/src/paperless/document-type.ts +74 -0
  64. package/src/paperless/errors.ts +62 -0
  65. package/src/paperless/fake-paperless.ts +156 -0
  66. package/src/paperless/generated/constraints/index.ts +23 -0
  67. package/src/paperless/generated/constraints/paperless-custom_fields.ts +29 -0
  68. package/src/paperless/generated/constraints/paperless-document_types.ts +32 -0
  69. package/src/paperless/generated/constraints/paperless-storage_paths.ts +34 -0
  70. package/src/paperless/generated/constraints/paperless-tags.ts +34 -0
  71. package/src/paperless/generated/types/custom-fields.ts +23 -0
  72. package/src/paperless/generated/types/document-types.ts +30 -0
  73. package/src/paperless/generated/types/index.ts +9 -0
  74. package/src/paperless/generated/types/storage-paths.ts +31 -0
  75. package/src/paperless/generated/types/tags.ts +37 -0
  76. package/src/paperless/index.ts +56 -0
  77. package/src/paperless/matching-identity.test.ts +194 -0
  78. package/src/paperless/matching-locate.ts +104 -0
  79. package/src/paperless/matching-types.ts +61 -0
  80. package/src/paperless/matching.test.ts +128 -0
  81. package/src/paperless/matching.ts +239 -0
  82. package/src/paperless/providers.ts +27 -0
  83. package/src/paperless/storage-path.ts +78 -0
  84. package/src/paperless/tag.ts +91 -0
@@ -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)));