@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,115 @@
|
|
|
1
|
+
# Paperless — `@homeflare/alchemy/paperless`
|
|
2
|
+
|
|
3
|
+
Four resources — `Paperless.Tag`, `Paperless.DocumentType`, `Paperless.StoragePath`,
|
|
4
|
+
`Paperless.CustomField` — the taxonomy Paperless-ngx writes through Django's internal models,
|
|
5
|
+
create-or-update and never deleted by default. Workflows and mail accounts are a later slice.
|
|
6
|
+
|
|
7
|
+
## Credentials
|
|
8
|
+
|
|
9
|
+
Two environment variables, read **at call time**, never props:
|
|
10
|
+
|
|
11
|
+
| variable | holds |
|
|
12
|
+
| ----------------- | --------------------------------------------------------- |
|
|
13
|
+
| `PAPERLESS_URL` | the instance origin, e.g. `https://paperless.example.com` |
|
|
14
|
+
| `PAPERLESS_TOKEN` | a scoped Paperless-ngx API token |
|
|
15
|
+
|
|
16
|
+
⛔ **Neither has a default.** A package that fell back to a loopback address would let a consumer
|
|
17
|
+
who forgot the variable watch every call fail against a machine that is not theirs.
|
|
18
|
+
|
|
19
|
+
⚠️ **`Authorization: Token <value>`, not `Bearer`** — Paperless-ngx's own `securitySchemes`
|
|
20
|
+
states the "Token" prefix explicitly.
|
|
21
|
+
|
|
22
|
+
⚠️ **Every call carries `Accept: application/json; version=10`**, pinned to the API version this
|
|
23
|
+
family was walked against (`REST_FRAMEWORK.DEFAULT_VERSION` at v3.1.1). An estate upgrade that
|
|
24
|
+
moves the default cannot silently change what this package receives without that assertion
|
|
25
|
+
failing first.
|
|
26
|
+
|
|
27
|
+
## Every create carries `owner`, null when undeclared
|
|
28
|
+
|
|
29
|
+
🔴 **This is the one behaviour that had to be measured, not guessed.**
|
|
30
|
+
`OwnedObjectSerializer.create` (Paperless-ngx `documents/serialisers.py:495-502` at v3.1.1) sets
|
|
31
|
+
the owner to the TOKEN USER whenever the field is absent from the body. Omitting an undeclared
|
|
32
|
+
prop the way you might expect — `Netbox.Prefix`'s pattern for an undeclared foreign key — would
|
|
33
|
+
silently create an object owned by whichever credential ran the deploy: invisible to every other
|
|
34
|
+
user, and a second row beside the estate's `owner: null` one on the next apply. So `Tag`,
|
|
35
|
+
`DocumentType` and `StoragePath` always send `owner: null` unless `owner` is declared.
|
|
36
|
+
|
|
37
|
+
⛔ `Paperless.CustomField` has **no owner field at all** — its wire body is exactly `{name,
|
|
38
|
+
data_type, extra_data}`, measured against the schema. It is not `OwnedObjectSerializer`-based.
|
|
39
|
+
|
|
40
|
+
## Adopt is never silent (H1)
|
|
41
|
+
|
|
42
|
+
★ **`read` answers `Unowned(attrs)` on every match**, never plain attributes. Paperless has no
|
|
43
|
+
ownership marker this package can check — `owner` is the estate's own prop, not proof of who
|
|
44
|
+
created a row — so, per the house's 2026-09-21 decision, identical is not ours. `--adopt` (or
|
|
45
|
+
`.pipe(adopt(true))`) is required to bind an existing tag, document type, storage path or custom
|
|
46
|
+
field, the same posture as any other marker-less API.
|
|
47
|
+
|
|
48
|
+
## `CustomField.dataType` is create-only
|
|
49
|
+
|
|
50
|
+
⛔ **A change is refused at plan time, never a PATCH and never a replace.** Changing a custom
|
|
51
|
+
field's data type in Paperless deletes that field's value on every document that carries it — a
|
|
52
|
+
replace (delete + recreate) is exactly as destructive as a PATCH would be, just under a different
|
|
53
|
+
name. Declaring a different `dataType` against a live object fails the plan with both values
|
|
54
|
+
named; retire the old field and create a new one instead.
|
|
55
|
+
|
|
56
|
+
⚠️ The reconciler this family replaces asserted "Paperless forbids a data_type change" — that
|
|
57
|
+
claim is **unverified** at v3.1.1 (`CustomFieldSerializer.validate` and `CustomFieldViewSet`
|
|
58
|
+
contain no such refusal). This package refuses the change regardless, because the destructive
|
|
59
|
+
side effect is real whether or not the vendor also refuses it.
|
|
60
|
+
|
|
61
|
+
## Removal is `retain`
|
|
62
|
+
|
|
63
|
+
Every family defaults to `retain` — a tag, document type, storage path or custom field id is
|
|
64
|
+
referenced by every document that carries it, and Paperless does not offer a "detach first"
|
|
65
|
+
option. `delete` is fully implemented; dropping a declaration does not delete the object unless
|
|
66
|
+
the caller opts into `.pipe(RemovalPolicy.destroy())`.
|
|
67
|
+
|
|
68
|
+
## Locate is by name only before there is state; identity is the stored id after
|
|
69
|
+
|
|
70
|
+
`MatchingModel` (Paperless-ngx `models.py:79-89`) has `UniqueConstraint(name, owner)` plus a
|
|
71
|
+
unique name where owner is null. Before a declaration has ever been deployed — a genuine first
|
|
72
|
+
create, or the engine's own `--adopt` probe — `matching.ts` locates candidates with a
|
|
73
|
+
case-insensitive `name__iexact` filter, wide enough to see every case-variant Paperless holds,
|
|
74
|
+
and then narrows to the exact-case, exact-owner row in this process (the same locate-then-identify
|
|
75
|
+
split `Netbox.Prefix` uses for its VRF). A locate that matches more than the 20-row page it reads
|
|
76
|
+
is refused as "not a natural key".
|
|
77
|
+
|
|
78
|
+
⛔ **Once a declaration has state, identity is the stored numeric id, not `(name, owner)`.**
|
|
79
|
+
Renaming a tag, or re-owning any of the three owned families, is an ordinary edit — and locating
|
|
80
|
+
by the _new_ name/owner would miss the row this declaration already wrote, PATCHing nothing and
|
|
81
|
+
POSTing a second object under the new name while the original sat there permanently orphaned
|
|
82
|
+
(`list()` always answers `[]` by design, so `nuke` can never find it either). `diff`/`reconcile`
|
|
83
|
+
locate by the id Alchemy's engine already has on hand (`output.id`) instead: a changed name/owner
|
|
84
|
+
becomes an in-place PATCH of that same id — Paperless allows renaming all four types by PATCH, and
|
|
85
|
+
every document keeps its tag/type/path/field because the id never changes. If that id no longer
|
|
86
|
+
exists live (deleted out of band), the plan refuses loudly rather than silently creating a
|
|
87
|
+
replacement; adopt the live object back or drop it from state first.
|
|
88
|
+
|
|
89
|
+
`destroy` locates the same way — by `output.id` once state exists, so a row renamed out of band
|
|
90
|
+
after its last deploy is still found and deleted by id rather than silently missed by a
|
|
91
|
+
now-stale name. Unlike `diff`/`reconcile`, a missing `output.id` at delete time is not a refusal:
|
|
92
|
+
the object being already gone is exactly the idempotent success `delete` reports.
|
|
93
|
+
|
|
94
|
+
## Generated, not hand-typed
|
|
95
|
+
|
|
96
|
+
`generated/types/*.ts` and `generated/constraints/*.ts` come from `bun codegen/paperless.ts`
|
|
97
|
+
against Paperless-ngx 3.1.1's own served OpenAPI document — see
|
|
98
|
+
[`codegen/paperless.md`](../../../codegen/paperless.md) for the full provenance chain. A prop's
|
|
99
|
+
enum-shaped fields (`matchingAlgorithm`, `dataType`) are typed by indexing into the generated
|
|
100
|
+
request interface (`PaperlessTagRequest['matching_algorithm']`), never retyped by hand.
|
|
101
|
+
|
|
102
|
+
## Usage
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { paperlessProviders, Tag } from '@homeflare/alchemy/paperless';
|
|
106
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
107
|
+
|
|
108
|
+
// PAPERLESS_URL and PAPERLESS_TOKEN in the deploy's environment.
|
|
109
|
+
export default async () => {
|
|
110
|
+
const app = await alchemy('paperless-taxonomy');
|
|
111
|
+
const invoices = await Tag('invoices', { name: 'Invoices', color: '#a6cee3' });
|
|
112
|
+
await app.finalize();
|
|
113
|
+
};
|
|
114
|
+
// wire the stack: Layer.mergeAll(paperlessProviders(), …).pipe(Layer.provide(FetchHttpClient.layer))
|
|
115
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.1",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -68,6 +68,10 @@
|
|
|
68
68
|
"types": "./dist/linux/index.d.ts",
|
|
69
69
|
"default": "./dist/linux/index.js"
|
|
70
70
|
},
|
|
71
|
+
"./paperless": {
|
|
72
|
+
"types": "./dist/paperless/index.d.ts",
|
|
73
|
+
"default": "./dist/paperless/index.js"
|
|
74
|
+
},
|
|
71
75
|
"./release": {
|
|
72
76
|
"types": "./dist/release/index.d.ts",
|
|
73
77
|
"default": "./dist/release/index.js"
|
|
@@ -185,6 +185,8 @@ export const fakeLinuxHost = (options: FakeLinuxOptions = {}) => {
|
|
|
185
185
|
enabled: state.enabled ?? false,
|
|
186
186
|
loadedSha: state.loadedSha === undefined ? digest(text) : state.loadedSha,
|
|
187
187
|
...(state.masked === undefined ? {} : { masked: state.masked }),
|
|
188
|
+
...(state.activeState === undefined ? {} : { activeState: state.activeState }),
|
|
189
|
+
...(state.subState === undefined ? {} : { subState: state.subState }),
|
|
188
190
|
});
|
|
189
191
|
};
|
|
190
192
|
|
|
@@ -15,6 +15,21 @@ export type UnitState = {
|
|
|
15
15
|
active: boolean;
|
|
16
16
|
enabled: boolean;
|
|
17
17
|
masked?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Force `ActiveState` to a value `start`/`stop`/`restart` below never produce, for tests: a
|
|
20
|
+
* timer-driven oneshot mid-run (`activating`, `active` left `false`) or a crashed unit (`failed`).
|
|
21
|
+
* ⚠️ REASONED NOT MEASURED — a synthetic override to exercise unit-form.ts's `isUnitRunning`
|
|
22
|
+
* exactly, not a systemd state-machine model; see fake-linux-host.ts's `placeUnit`.
|
|
23
|
+
*/
|
|
24
|
+
activeState?: 'activating' | 'failed';
|
|
25
|
+
/**
|
|
26
|
+
* Force `SubState` independent of the default derived from `activeState` — `'start'` is already
|
|
27
|
+
* the default for `activeState: 'activating'` and is accepted here only so a test can say so
|
|
28
|
+
* explicitly. Systemd's crash-restart backoff (`auto-restart` / `auto-restart-queued`, both still
|
|
29
|
+
* `ActiveState=activating` — systemd's `service.c` `state_translation_table`) needs
|
|
30
|
+
* `activeState: 'activating'` ABOVE plus one of these, to tell it apart from a genuine start.
|
|
31
|
+
*/
|
|
32
|
+
subState?: 'start' | 'auto-restart' | 'auto-restart-queued';
|
|
18
33
|
};
|
|
19
34
|
|
|
20
35
|
export type FakeSystemdDeps = {
|
|
@@ -45,12 +60,24 @@ export const fakeSystemd = (deps: FakeSystemdDeps) => {
|
|
|
45
60
|
if (deps.failShow) return fail(1, 'Failed to connect to bus: No such file');
|
|
46
61
|
const known = state !== undefined || onDisk !== undefined;
|
|
47
62
|
const loaded = state?.loadedSha !== undefined;
|
|
63
|
+
// ★ `activeState`/`subState` (above) override the plain active/inactive read for a state
|
|
64
|
+
// start/stop/restart never produce on their own — see `UnitState.activeState`.
|
|
65
|
+
const activeState = state?.activeState ?? (state?.active === true ? 'active' : 'inactive');
|
|
66
|
+
const subState =
|
|
67
|
+
state?.subState ??
|
|
68
|
+
(activeState === 'activating'
|
|
69
|
+
? 'start'
|
|
70
|
+
: activeState === 'failed'
|
|
71
|
+
? 'failed'
|
|
72
|
+
: activeState === 'active'
|
|
73
|
+
? 'running'
|
|
74
|
+
: 'dead');
|
|
48
75
|
return ok(
|
|
49
76
|
[
|
|
50
77
|
`Id=${name}`,
|
|
51
78
|
`LoadState=${state?.masked === true ? 'masked' : known && loaded ? 'loaded' : 'not-found'}`,
|
|
52
|
-
`ActiveState=${
|
|
53
|
-
`SubState=${
|
|
79
|
+
`ActiveState=${activeState}`,
|
|
80
|
+
`SubState=${subState}`,
|
|
54
81
|
`UnitFileState=${state?.masked === true ? 'masked' : onDisk === undefined ? '' : state?.enabled === true ? 'enabled' : 'disabled'}`,
|
|
55
82
|
`FragmentPath=${onDisk === undefined ? '' : deps.pathOf(name)}`,
|
|
56
83
|
`NeedDaemonReload=${onDisk !== undefined && state?.loadedSha !== deps.digest(onDisk) && loaded ? 'yes' : 'no'}`,
|
|
@@ -92,10 +119,17 @@ export const fakeSystemd = (deps: FakeSystemdDeps) => {
|
|
|
92
119
|
}
|
|
93
120
|
if (verb === 'start' || verb === 'restart') {
|
|
94
121
|
current.active = true;
|
|
122
|
+
// ★ A real start/restart leaves whatever synthetic `activating`/`auto-restart` snapshot a
|
|
123
|
+
// test placed and lands the unit in an ordinary `active` — the override does not survive
|
|
124
|
+
// an actual systemctl action, any more than it would on a real host.
|
|
125
|
+
delete current.activeState;
|
|
126
|
+
delete current.subState;
|
|
95
127
|
return ok();
|
|
96
128
|
}
|
|
97
129
|
if (verb === 'stop') {
|
|
98
130
|
current.active = false;
|
|
131
|
+
delete current.activeState;
|
|
132
|
+
delete current.subState;
|
|
99
133
|
return ok();
|
|
100
134
|
}
|
|
101
135
|
return fail(64, `fake systemctl: unsupported ${args.join(' ')}`);
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `started: false` against a unit that is `ActiveState=activating` — the timer-driven `Type=oneshot`
|
|
3
|
+
* scenario `isUnitRunning` (unit-form.ts) exists for, and the crash-restart (`auto-restart`)
|
|
4
|
+
* `SubState` that scenario must not be confused with. Split from unit-lifecycle.test.ts to stay
|
|
5
|
+
* under this file's line cap; see that file for the rest of the family's behaviour.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ THE BUG THIS GUARDS: before `isUnitRunning`, `diffUnit` read `ActiveState=activating` on a
|
|
8
|
+
* `started: false` unit as drift and planned `update`; `settle` then called `systemctl stop` on a
|
|
9
|
+
* check that was already running, mid-run, driven by its own `.timer`. A redeploy must plan to
|
|
10
|
+
* nothing while the run is in progress — these tests are that redeploy.
|
|
11
|
+
* ⛔ THE SECOND BUG THAT ALMOST SHIPPED WITH THE FIRST FIX: an `activating` unit whose `SubState`
|
|
12
|
+
* is `auto-restart` (or `auto-restart-queued`) is systemd's crash-restart backoff, not a start —
|
|
13
|
+
* see isUnitRunning's own comment. A `started: false` unit that is crash-looping must still be
|
|
14
|
+
* stopped; the "auto-restart backoff is still drift" test below is that case.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, test } from 'bun:test';
|
|
17
|
+
import { fakeLinuxHost } from './fake-linux-host.ts';
|
|
18
|
+
import { type SystemdUnitProps, isUnitRunning, renderUnit } from './unit-form.ts';
|
|
19
|
+
import { diffUnit, readUnit, reconcileUnit } from './unit-lifecycle.ts';
|
|
20
|
+
|
|
21
|
+
const NAME = 'hf-node-check.service';
|
|
22
|
+
const PATH = `/etc/systemd/system/${NAME}`;
|
|
23
|
+
/** ⛔ No `[Install]`: a timer-driven oneshot is `enabled: false` — its `.timer` starts it, not `systemctl enable`. */
|
|
24
|
+
const content = renderUnit([
|
|
25
|
+
{ lines: [['Description', 'a PVE node check']], name: 'Unit' },
|
|
26
|
+
{
|
|
27
|
+
lines: [
|
|
28
|
+
['Type', 'oneshot'],
|
|
29
|
+
['ExecStart', '/usr/bin/check-node'],
|
|
30
|
+
],
|
|
31
|
+
name: 'Service',
|
|
32
|
+
},
|
|
33
|
+
]);
|
|
34
|
+
const props: SystemdUnitProps = { content, enabled: false, name: NAME, started: false };
|
|
35
|
+
const host = () => fakeLinuxHost({ dirs: { '/etc/systemd/system': 0 } });
|
|
36
|
+
const verbs = (calls: string[][]) =>
|
|
37
|
+
calls.filter((call) => call[0] === 'systemctl').map((call) => call[1]);
|
|
38
|
+
|
|
39
|
+
/** `readUnit` narrowed to defined, so callers can pass it straight to diffUnit/reconcileUnit. */
|
|
40
|
+
const outputFor = async (fake: ReturnType<typeof host>, of: SystemdUnitProps) => {
|
|
41
|
+
const found = await readUnit(fake.runner, of);
|
|
42
|
+
if (found === undefined) throw new Error(`${of.name} not found on the fake host`);
|
|
43
|
+
return found;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
describe('isUnitRunning — the one predicate diff and settle share', () => {
|
|
47
|
+
test('active always counts as running, whatever started declares', () => {
|
|
48
|
+
expect(isUnitRunning('active', 'running', true)).toBe(true);
|
|
49
|
+
expect(isUnitRunning('active', 'running', false)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
test('a genuine start counts as running only when started is declared', () => {
|
|
52
|
+
expect(isUnitRunning('activating', 'start', true)).toBe(true);
|
|
53
|
+
expect(isUnitRunning('activating', 'start', false)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
// ⛔ Added on adversarial review: `auto-restart`/`auto-restart-queued` are ALSO
|
|
56
|
+
// `ActiveState=activating` (systemd's crash-restart backoff, service.c), not a fresh start —
|
|
57
|
+
// see isUnitRunning's own comment. Without this, started: false would stop enforcing itself on
|
|
58
|
+
// exactly the unit most in need of it: one that is crash-looping.
|
|
59
|
+
test('auto-restart backoff always counts as running — it is a crash loop, not a fresh start', () => {
|
|
60
|
+
expect(isUnitRunning('activating', 'auto-restart', false)).toBe(true);
|
|
61
|
+
expect(isUnitRunning('activating', 'auto-restart-queued', false)).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
test('inactive and failed never count as running', () => {
|
|
64
|
+
expect(isUnitRunning('inactive', 'dead', true)).toBe(false);
|
|
65
|
+
expect(isUnitRunning('failed', 'failed', false)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('started: false against a oneshot mid-run', () => {
|
|
70
|
+
test('activating, everything else converged: diff is noop', async () => {
|
|
71
|
+
const fake = host();
|
|
72
|
+
fake.placeUnit(PATH, content, { activeState: 'activating', enabled: false, subState: 'start' });
|
|
73
|
+
const output = await outputFor(fake, props);
|
|
74
|
+
expect(await diffUnit(fake.runner, props, output)).toEqual({ action: 'noop' });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('active is drift: diff is update, and settle stops it', async () => {
|
|
78
|
+
const fake = host();
|
|
79
|
+
fake.placeUnit(PATH, content, { active: true, enabled: false });
|
|
80
|
+
const output = await outputFor(fake, props);
|
|
81
|
+
expect(await diffUnit(fake.runner, props, output)).toEqual({ action: 'update' });
|
|
82
|
+
const before = fake.calls.length;
|
|
83
|
+
const settled = await reconcileUnit(fake.runner, props, output);
|
|
84
|
+
expect(verbs(fake.calls.slice(before))).toContain('stop');
|
|
85
|
+
expect(settled.active).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('activating during an unrelated unit-file update: settle does not stop it', async () => {
|
|
89
|
+
const fake = host();
|
|
90
|
+
fake.placeUnit(PATH, content, { activeState: 'activating', enabled: false, subState: 'start' });
|
|
91
|
+
const output = await outputFor(fake, props);
|
|
92
|
+
const changed = { ...props, content: content.replace('check-node', 'check-node-v2') };
|
|
93
|
+
expect(await diffUnit(fake.runner, changed, output)).toEqual({ action: 'update' });
|
|
94
|
+
const before = fake.calls.length;
|
|
95
|
+
await reconcileUnit(fake.runner, changed, output);
|
|
96
|
+
expect(verbs(fake.calls.slice(before))).not.toContain('stop');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('failed is not running either: diff is noop', async () => {
|
|
100
|
+
const fake = host();
|
|
101
|
+
fake.placeUnit(PATH, content, { activeState: 'failed', enabled: false });
|
|
102
|
+
const output = await outputFor(fake, props);
|
|
103
|
+
expect(await diffUnit(fake.runner, props, output)).toEqual({ action: 'noop' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ⛔ ADVERSARIAL-REVIEW CASE: `activating` is not only a fresh start. A unit that was started by
|
|
107
|
+
// hand and now crash-loops (`Restart=on-failure`) sits in `ActiveState=activating`,
|
|
108
|
+
// `SubState=auto-restart` for nearly all of its wall-clock time. The oneshot exemption above
|
|
109
|
+
// must NOT swallow this one — started: false has to keep stopping it, same as plain `active`.
|
|
110
|
+
test('auto-restart backoff is still drift: diff is update, and settle still stops it', async () => {
|
|
111
|
+
const fake = host();
|
|
112
|
+
const crashy = renderUnit([
|
|
113
|
+
{ lines: [['Description', 'started by hand, now crash-looping']], name: 'Unit' },
|
|
114
|
+
{
|
|
115
|
+
lines: [
|
|
116
|
+
['ExecStart', '/usr/bin/flaky'],
|
|
117
|
+
['Restart', 'on-failure'],
|
|
118
|
+
['RestartSec', '5'],
|
|
119
|
+
],
|
|
120
|
+
name: 'Service',
|
|
121
|
+
},
|
|
122
|
+
]);
|
|
123
|
+
const crashyProps: SystemdUnitProps = {
|
|
124
|
+
content: crashy,
|
|
125
|
+
enabled: false,
|
|
126
|
+
name: NAME,
|
|
127
|
+
started: false,
|
|
128
|
+
};
|
|
129
|
+
fake.placeUnit(PATH, crashy, {
|
|
130
|
+
activeState: 'activating',
|
|
131
|
+
enabled: false,
|
|
132
|
+
subState: 'auto-restart',
|
|
133
|
+
});
|
|
134
|
+
const output = await outputFor(fake, crashyProps);
|
|
135
|
+
expect(await diffUnit(fake.runner, crashyProps, output)).toEqual({ action: 'update' });
|
|
136
|
+
const before = fake.calls.length;
|
|
137
|
+
const settled = await reconcileUnit(fake.runner, crashyProps, output);
|
|
138
|
+
expect(verbs(fake.calls.slice(before))).toContain('stop');
|
|
139
|
+
expect(settled.active).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('started: true (or omitted) keeps counting activating as running', () => {
|
|
144
|
+
test('no start is issued while the unit is already activating', async () => {
|
|
145
|
+
const fake = host();
|
|
146
|
+
const enabledContent = renderUnit([
|
|
147
|
+
{ lines: [['Description', 'a long-running service']], name: 'Unit' },
|
|
148
|
+
{ lines: [['ExecStart', '/usr/bin/thing']], name: 'Service' },
|
|
149
|
+
{ lines: [['WantedBy', 'multi-user.target']], name: 'Install' },
|
|
150
|
+
]);
|
|
151
|
+
const started: SystemdUnitProps = { content: enabledContent, name: NAME };
|
|
152
|
+
fake.placeUnit(PATH, enabledContent, {
|
|
153
|
+
activeState: 'activating',
|
|
154
|
+
enabled: true,
|
|
155
|
+
subState: 'start',
|
|
156
|
+
});
|
|
157
|
+
const output = await outputFor(fake, started);
|
|
158
|
+
expect(await diffUnit(fake.runner, started, output)).toEqual({ action: 'noop' });
|
|
159
|
+
const before = fake.calls.length;
|
|
160
|
+
const settled = await reconcileUnit(fake.runner, started, output);
|
|
161
|
+
expect(verbs(fake.calls.slice(before))).not.toContain('start');
|
|
162
|
+
expect(settled.active).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
});
|
package/src/linux/unit-form.ts
CHANGED
|
@@ -90,6 +90,57 @@ export const digestOf = (text: string): string => sha256Hex(new TextEncoder().en
|
|
|
90
90
|
export const configDigest = (restartOn: readonly string[] | undefined): string =>
|
|
91
91
|
digestOf((restartOn ?? []).join('\n'));
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Whether `activeState`/`subState` count as "the unit is running" against a declared `started` —
|
|
95
|
+
* the ONE place this decision is made, so `diffUnit` (unit-lifecycle.ts) and `settle`
|
|
96
|
+
* (unit-settle.ts) can never disagree about it.
|
|
97
|
+
*
|
|
98
|
+
* ⛔ WHY THIS EXISTS: a `Type=oneshot` service driven by a `.timer` (no `[Install]`, declared
|
|
99
|
+
* `started: false, enabled: false` — its timer starts it, not this resource) reports
|
|
100
|
+
* `ActiveState=activating` for the run's whole duration, not an instant. Written for the PVE
|
|
101
|
+
* node-check unit that first hit this: ~1.2s, every 60s. A plan taken mid-run must not read that
|
|
102
|
+
* run as drift, and a deploy must not `systemctl stop` a check that is already in flight.
|
|
103
|
+
* ★ systemd.service(5) §`Type=`, `oneshot` (man7.org mirror, read 2026-09-23): with no
|
|
104
|
+
* `RemainAfterExit=`, the service manager holds the unit `activating` for as long as
|
|
105
|
+
* `ExecStart=` runs, then moves it straight to `dead`/`inactive` (or `failed`) — it never passes
|
|
106
|
+
* through `active`. So for `started: false`, `activating` is exactly a run in progress, and
|
|
107
|
+
* `active` is the one state a oneshot never reaches on its own: that is the drift, and the only
|
|
108
|
+
* thing settle stops.
|
|
109
|
+
* ⛔ CRASH-LOOP GUARD, added on adversarial review: `ActiveState=activating` is not only "starting
|
|
110
|
+
* for the first time" — a service with `Restart=` waiting out its `RestartSec=` backoff between
|
|
111
|
+
* crash attempts is ALSO `activating`, with `SubState=auto-restart` (or `auto-restart-queued`).
|
|
112
|
+
* A unit declared `started: false` that is crash-looping sits there for nearly all of its
|
|
113
|
+
* wall-clock time. Without this guard the oneshot exemption above would read that loop as
|
|
114
|
+
* converged forever, and a deploy would never again issue the `stop` that ends it — silently
|
|
115
|
+
* defeating `started: false` on exactly the unit that most needs it enforced. So only a genuine
|
|
116
|
+
* start (`SubState` anything else — `start`, `start-pre`, `start-post`, …) gets the exemption;
|
|
117
|
+
* an auto-restart substate counts as running regardless of `started`, same as `active` does.
|
|
118
|
+
* ★ SOURCE, not measured against a live host: systemd's own `service.c`
|
|
119
|
+
* `state_translation_table` (github.com/systemd/systemd, read 2026-09-23) maps both
|
|
120
|
+
* `SERVICE_AUTO_RESTART` and `SERVICE_AUTO_RESTART_QUEUED` to `UNIT_ACTIVATING`, and
|
|
121
|
+
* `unit-def.c`'s `service_state_table` names their `SubState` strings `"auto-restart"` and
|
|
122
|
+
* `"auto-restart-queued"`. ⚠️ REASONED NOT MEASURED: no unit was actually crash-looped and read
|
|
123
|
+
* back with `systemctl show` for this change — the systemd source is the citation, unlike
|
|
124
|
+
* systemctl.ts's own header, which is a live-host trace.
|
|
125
|
+
* ⚠️ THE TRADE-OFF THIS ACCEPTS: a `Type=notify` (or other long-running type) unit stuck in a
|
|
126
|
+
* genuine `activating`/`start` with `started: false` — never reaching `active` and never
|
|
127
|
+
* crash-looping either — reads as converged, not drift, until it reaches `active`. A unit hung
|
|
128
|
+
* like that is exactly the case a human should look at, not one this predicate auto-corrects by
|
|
129
|
+
* stopping it out from under whatever else depends on it.
|
|
130
|
+
* ★ `started: true` (the default, when omitted) is unchanged: `active` or `activating` (any
|
|
131
|
+
* `subState`) both count as running, so a start already in flight is never started twice.
|
|
132
|
+
*/
|
|
133
|
+
export const isUnitRunning = (
|
|
134
|
+
activeState: string,
|
|
135
|
+
subState: string | undefined,
|
|
136
|
+
started: boolean,
|
|
137
|
+
): boolean => {
|
|
138
|
+
if (activeState === 'active') return true;
|
|
139
|
+
if (activeState !== 'activating') return false;
|
|
140
|
+
if (subState === 'auto-restart' || subState === 'auto-restart-queued') return true;
|
|
141
|
+
return started;
|
|
142
|
+
};
|
|
143
|
+
|
|
93
144
|
/** Sections → an INI unit file. ★ Keys and values pass through untouched; see the file header. */
|
|
94
145
|
export const renderUnit = (sections: readonly UnitSection[]): string =>
|
|
95
146
|
`${sections
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
UNIT_WRITE,
|
|
28
28
|
configDigest,
|
|
29
29
|
digestOf,
|
|
30
|
+
isUnitRunning,
|
|
30
31
|
unitPathFor,
|
|
31
32
|
unitText,
|
|
32
33
|
} from './unit-form.ts';
|
|
@@ -75,10 +76,16 @@ export const diffUnit = async (
|
|
|
75
76
|
const live = await readUnit(runner, news);
|
|
76
77
|
if (live === undefined || live.unitSha256 !== desired) return update();
|
|
77
78
|
if (live.enabled !== (news.enabled !== false)) return update();
|
|
78
|
-
if (live.active !== (news.started !== false)) return update();
|
|
79
79
|
// ⚠️ systemd's own word for "the file on disk is newer than what I loaded" — a write that landed
|
|
80
|
-
// without its reload. It is drift that neither digest can see.
|
|
80
|
+
// without its reload. It is drift that neither digest can see. ★ Also the freshest read of
|
|
81
|
+
// ActiveState/SubState — not `live.active`, which is a raw "active or activating" reading with
|
|
82
|
+
// no SubState in it. Whether the current state counts as drift against `started` is the
|
|
83
|
+
// isUnitRunning policy (unit-form.ts), shared with settle so the two can never disagree.
|
|
81
84
|
const status = await showUnit(runner, news.name);
|
|
85
|
+
const wantStarted = news.started !== false;
|
|
86
|
+
if (isUnitRunning(status.activeState, status.subState, wantStarted) !== wantStarted) {
|
|
87
|
+
return update();
|
|
88
|
+
}
|
|
82
89
|
return status.needDaemonReload ? update() : { action: 'noop' };
|
|
83
90
|
};
|
|
84
91
|
|
package/src/linux/unit-settle.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type SystemdUnitAttributes,
|
|
14
14
|
type SystemdUnitProps,
|
|
15
15
|
configDigest,
|
|
16
|
+
isUnitRunning,
|
|
16
17
|
unitPathFor,
|
|
17
18
|
} from './unit-form.ts';
|
|
18
19
|
import {
|
|
@@ -81,10 +82,13 @@ export const settle = async (
|
|
|
81
82
|
try {
|
|
82
83
|
if (wantEnabled && (!isEnabled || step.changed)) await enableUnit(runner, props.name);
|
|
83
84
|
if (!wantEnabled && isEnabled) await disableUnit(runner, props.name);
|
|
84
|
-
|
|
85
|
+
// ★ isUnitRunning (unit-form.ts): the same started-aware policy diffUnit uses, so a unit this
|
|
86
|
+
// deploy never touches — a `started: false` oneshot mid-run — is never stopped here either.
|
|
87
|
+
// `subState` is what tells that mid-run apart from a crash-restart backoff, which still stops.
|
|
88
|
+
const running = isUnitRunning(step.status.activeState, step.status.subState, wantStarted);
|
|
85
89
|
if (!wantStarted) {
|
|
86
|
-
if (
|
|
87
|
-
} else if (!
|
|
90
|
+
if (running) await stopUnit(runner, props.name);
|
|
91
|
+
} else if (!running) await startUnit(runner, props.name);
|
|
88
92
|
else if (step.changed || step.configChanged) await restartUnit(runner, props.name);
|
|
89
93
|
} catch (cause) {
|
|
90
94
|
return undo(cause instanceof Error ? cause.message : String(cause));
|
|
@@ -96,7 +100,7 @@ export const settle = async (
|
|
|
96
100
|
* as drift, in either direction.
|
|
97
101
|
*/
|
|
98
102
|
const after = await showUnit(runner, props.name);
|
|
99
|
-
if (wantStarted && after.activeState
|
|
103
|
+
if (wantStarted && !isUnitRunning(after.activeState, after.subState, wantStarted)) {
|
|
100
104
|
return undo(`systemctl reported success but the unit is ${after.activeState}.`);
|
|
101
105
|
}
|
|
102
106
|
const settled = attributesOf(props, step.desired, after);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One authenticated call to the Paperless-ngx REST API at `/api`.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ `Authorization: Token <value>`, NOT `Bearer`. Paperless-ngx's `securitySchemes.tokenAuth`
|
|
5
|
+
* (the v3.1.1 served schema) states the "Token" prefix explicitly — the same DRF
|
|
6
|
+
* `TokenAuthentication` NetBox uses (netbox/client.ts), a different vendor, same convention.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ `Accept: application/json; version=10` ON EVERY CALL. Paperless-ngx versions its API with
|
|
9
|
+
* DRF's `AcceptHeaderVersioning` (`REST_FRAMEWORK.DEFAULT_VERSION = '10'`, measured against the
|
|
10
|
+
* v3.1.1 source). Omitting the header still answers — DRF falls back to `DEFAULT_VERSION` — but
|
|
11
|
+
* pinning it here means an estate upgrade that moves the default cannot silently change what
|
|
12
|
+
* this package receives without a version bump of its own noticing.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ A LIST IS WRAPPED, A DETAIL IS NOT: `{count, next, previous, results: […]}` vs. the bare
|
|
15
|
+
* object — the same DRF pagination shape NetBox uses.
|
|
16
|
+
*/
|
|
17
|
+
import * as Effect from 'effect/Effect';
|
|
18
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
19
|
+
import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
|
|
20
|
+
import { PaperlessCredentials, type PaperlessCredentialsError } from './credentials.ts';
|
|
21
|
+
import { type PaperlessError, PaperlessUnavailable, statusToError } from './errors.ts';
|
|
22
|
+
|
|
23
|
+
const PATH_PREFIX = '/api';
|
|
24
|
+
const API_VERSION = '10';
|
|
25
|
+
|
|
26
|
+
export interface PaperlessList<T> {
|
|
27
|
+
readonly count: number;
|
|
28
|
+
readonly next: string | null;
|
|
29
|
+
readonly previous: string | null;
|
|
30
|
+
readonly results: readonly T[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type PaperlessRow = Record<string, unknown>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* ⚠️ `PaperlessCredentials` IS PART OF THIS, NOT JUST `HttpClient`. `paperless()` reads the lazy
|
|
37
|
+
* credentials service (`yield* yield* PaperlessCredentials`), so a stack that wires the
|
|
38
|
+
* providers without also providing a credentials layer should fail to TYPECHECK, not fail at
|
|
39
|
+
* the first call. `providers.ts` supplies `environmentLayer` by default for exactly this.
|
|
40
|
+
*/
|
|
41
|
+
export type PaperlessRequirements = HttpClient.HttpClient | PaperlessCredentials;
|
|
42
|
+
|
|
43
|
+
/** One JSON call. Returns the parsed body, or `undefined` for an empty response (DELETE). */
|
|
44
|
+
export const paperless = <T>(
|
|
45
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
46
|
+
path: string,
|
|
47
|
+
body?: Record<string, unknown>,
|
|
48
|
+
): Effect.Effect<T, PaperlessError | PaperlessCredentialsError, PaperlessRequirements> =>
|
|
49
|
+
Effect.gen(function* () {
|
|
50
|
+
const credentials = yield* yield* PaperlessCredentials;
|
|
51
|
+
const url = `${credentials.url}${PATH_PREFIX}/${path.replace(/^\//, '')}`;
|
|
52
|
+
const client = yield* HttpClient.HttpClient;
|
|
53
|
+
const request = HttpClientRequest.make(method)(url).pipe(
|
|
54
|
+
HttpClientRequest.setHeaders({
|
|
55
|
+
Accept: `application/json; version=${API_VERSION}`,
|
|
56
|
+
Authorization: `Token ${credentials.token}`,
|
|
57
|
+
}),
|
|
58
|
+
// ⛔ Content-Type on `bodyText`, not the header map — `bodyText(s)` with no second argument
|
|
59
|
+
// sets `text/plain` and overwrites a `Content-Type` set earlier via `setHeaders`, because
|
|
60
|
+
// it is piped afterwards (measured against `@homeflare/alchemy/forgejo`, netbox/client.ts).
|
|
61
|
+
body === undefined
|
|
62
|
+
? (self) => self
|
|
63
|
+
: HttpClientRequest.bodyText(JSON.stringify(body), 'application/json'),
|
|
64
|
+
);
|
|
65
|
+
const response = yield* client
|
|
66
|
+
.execute(request)
|
|
67
|
+
.pipe(
|
|
68
|
+
Effect.mapError(
|
|
69
|
+
(cause) => new PaperlessUnavailable({ detail: String(cause), method, path, status: 0 }),
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
if (response.status < 200 || response.status >= 300) {
|
|
73
|
+
const detail = yield* response.text.pipe(Effect.orElseSucceed(() => ''));
|
|
74
|
+
return yield* Effect.fail(statusToError(response.status, method, path, detail.slice(0, 500)));
|
|
75
|
+
}
|
|
76
|
+
const text = yield* response.text.pipe(
|
|
77
|
+
Effect.mapError(
|
|
78
|
+
() =>
|
|
79
|
+
new PaperlessUnavailable({
|
|
80
|
+
detail: 'body read failed',
|
|
81
|
+
method,
|
|
82
|
+
path,
|
|
83
|
+
status: response.status,
|
|
84
|
+
}),
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
return (text === '' ? undefined : JSON.parse(text)) as T;
|
|
88
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generated tables, looked up by endpoint, and the refusal that stops a plan.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ AN UNKNOWN KEY IS A DEFECT, NOT A REASON TO SKIP THE CHECK — the generator resolves every key
|
|
5
|
+
* in its source scan against the vendor document, so a key absent here means the tables were
|
|
6
|
+
* not regenerated, which the message says. Same posture as `../netbox/constraint-guard.ts`.
|
|
7
|
+
*/
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import {
|
|
10
|
+
type EndpointConstraints,
|
|
11
|
+
type EndpointKey,
|
|
12
|
+
type PaperlessBody,
|
|
13
|
+
refusal,
|
|
14
|
+
violations,
|
|
15
|
+
} from './constraints.ts';
|
|
16
|
+
import { PAPERLESS_CONSTRAINTS } from './generated/constraints/index.ts';
|
|
17
|
+
|
|
18
|
+
export { PAPERLESS_CONSTRAINTS };
|
|
19
|
+
|
|
20
|
+
export const constraintsFor = (key: EndpointKey): EndpointConstraints => {
|
|
21
|
+
const table = PAPERLESS_CONSTRAINTS[key];
|
|
22
|
+
if (table === undefined) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`no vendor constraint table for '${key}'. It is named in this package but not generated: ` +
|
|
25
|
+
'run `bun codegen/paperless.ts` (see codegen/README.md).',
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return table;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const bodyViolations = (
|
|
32
|
+
key: EndpointKey,
|
|
33
|
+
body: PaperlessBody,
|
|
34
|
+
presence: boolean,
|
|
35
|
+
): readonly string[] => violations(constraintsFor(key), body, { presence });
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* ⛔ `Effect.die`, NOT `Effect.fail`. A declaration the vendor would reject is a DEFECT in the
|
|
39
|
+
* stack file, not a recoverable condition.
|
|
40
|
+
*/
|
|
41
|
+
export const guardBody = (
|
|
42
|
+
key: EndpointKey | undefined,
|
|
43
|
+
body: PaperlessBody,
|
|
44
|
+
presence: boolean,
|
|
45
|
+
): Effect.Effect<void> =>
|
|
46
|
+
Effect.suspend(() => {
|
|
47
|
+
if (key === undefined) return Effect.void;
|
|
48
|
+
const found = bodyViolations(key, body, presence);
|
|
49
|
+
return found.length === 0 ? Effect.void : Effect.die(new Error(refusal(key, found)));
|
|
50
|
+
});
|