@homeflare/alchemy 0.24.0 → 0.25.1
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.
- package/README.md +10 -0
- package/dist/linux/fake-linux-host.d.ts.map +1 -1
- package/dist/linux/fake-systemd.d.ts +15 -0
- package/dist/linux/fake-systemd.d.ts.map +1 -1
- package/dist/linux/index.js +18 -7
- package/dist/linux/index.js.map +5 -5
- package/dist/linux/unit-form.d.ts +41 -0
- package/dist/linux/unit-form.d.ts.map +1 -1
- package/dist/linux/unit-lifecycle.d.ts.map +1 -1
- package/dist/linux/unit-settle.d.ts.map +1 -1
- package/dist/paperless/client.d.ts +37 -0
- package/dist/paperless/client.d.ts.map +1 -0
- package/dist/paperless/constraint-guard.d.ts +19 -0
- package/dist/paperless/constraint-guard.d.ts.map +1 -0
- package/dist/paperless/constraints.d.ts +50 -0
- package/dist/paperless/constraints.d.ts.map +1 -0
- package/dist/paperless/credentials.d.ts +40 -0
- package/dist/paperless/credentials.d.ts.map +1 -0
- package/dist/paperless/custom-field.d.ts +75 -0
- package/dist/paperless/custom-field.d.ts.map +1 -0
- package/dist/paperless/document-type.d.ts +30 -0
- package/dist/paperless/document-type.d.ts.map +1 -0
- package/dist/paperless/errors.d.ts +49 -0
- package/dist/paperless/errors.d.ts.map +1 -0
- package/dist/paperless/fake-paperless.d.ts +39 -0
- package/dist/paperless/fake-paperless.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/index.d.ts +13 -0
- package/dist/paperless/generated/constraints/index.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts +21 -0
- package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/paperless-document_types.d.ts +21 -0
- package/dist/paperless/generated/constraints/paperless-document_types.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts +21 -0
- package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/paperless-tags.d.ts +21 -0
- package/dist/paperless/generated/constraints/paperless-tags.d.ts.map +1 -0
- package/dist/paperless/generated/types/custom-fields.d.ts +23 -0
- package/dist/paperless/generated/types/custom-fields.d.ts.map +1 -0
- package/dist/paperless/generated/types/document-types.d.ts +39 -0
- package/dist/paperless/generated/types/document-types.d.ts.map +1 -0
- package/dist/paperless/generated/types/index.d.ts +10 -0
- package/dist/paperless/generated/types/index.d.ts.map +1 -0
- package/dist/paperless/generated/types/storage-paths.d.ts +31 -0
- package/dist/paperless/generated/types/storage-paths.d.ts.map +1 -0
- package/dist/paperless/generated/types/tags.d.ts +37 -0
- package/dist/paperless/generated/types/tags.d.ts.map +1 -0
- package/dist/paperless/index.d.ts +19 -0
- package/dist/paperless/index.d.ts.map +1 -0
- package/dist/paperless/index.js +524 -0
- package/dist/paperless/index.js.map +26 -0
- package/dist/paperless/matching-locate.d.ts +33 -0
- package/dist/paperless/matching-locate.d.ts.map +1 -0
- package/dist/paperless/matching-types.d.ts +60 -0
- package/dist/paperless/matching-types.d.ts.map +1 -0
- package/dist/paperless/matching.d.ts +61 -0
- package/dist/paperless/matching.d.ts.map +1 -0
- package/dist/paperless/providers.d.ts +15 -0
- package/dist/paperless/providers.d.ts.map +1 -0
- package/dist/paperless/storage-path.d.ts +32 -0
- package/dist/paperless/storage-path.d.ts.map +1 -0
- package/dist/paperless/tag.d.ts +47 -0
- package/dist/paperless/tag.d.ts.map +1 -0
- package/docs/linux-host.md +2 -0
- package/docs/paperless.md +115 -0
- package/package.json +5 -1
- package/src/linux/fake-linux-host.ts +2 -0
- package/src/linux/fake-systemd.ts +36 -2
- package/src/linux/unit-activating.test.ts +164 -0
- package/src/linux/unit-form.ts +51 -0
- package/src/linux/unit-lifecycle.ts +9 -2
- package/src/linux/unit-settle.ts +8 -4
- package/src/paperless/client.ts +88 -0
- package/src/paperless/constraint-guard.ts +50 -0
- package/src/paperless/constraints.test.ts +74 -0
- package/src/paperless/constraints.ts +111 -0
- package/src/paperless/credentials.ts +69 -0
- package/src/paperless/custom-field.test.ts +74 -0
- package/src/paperless/custom-field.ts +95 -0
- package/src/paperless/document-type.ts +74 -0
- package/src/paperless/errors.ts +62 -0
- package/src/paperless/fake-paperless.ts +156 -0
- package/src/paperless/generated/constraints/index.ts +23 -0
- package/src/paperless/generated/constraints/paperless-custom_fields.ts +29 -0
- package/src/paperless/generated/constraints/paperless-document_types.ts +32 -0
- package/src/paperless/generated/constraints/paperless-storage_paths.ts +34 -0
- package/src/paperless/generated/constraints/paperless-tags.ts +34 -0
- package/src/paperless/generated/types/custom-fields.ts +23 -0
- package/src/paperless/generated/types/document-types.ts +30 -0
- package/src/paperless/generated/types/index.ts +9 -0
- package/src/paperless/generated/types/storage-paths.ts +31 -0
- package/src/paperless/generated/types/tags.ts +37 -0
- package/src/paperless/index.ts +56 -0
- package/src/paperless/matching-identity.test.ts +194 -0
- package/src/paperless/matching-locate.ts +104 -0
- package/src/paperless/matching-types.ts +61 -0
- package/src/paperless/matching.test.ts +128 -0
- package/src/paperless/matching.ts +239 -0
- package/src/paperless/providers.ts +27 -0
- package/src/paperless/storage-path.ts +78 -0
- 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)));
|