@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.
Files changed (100) hide show
  1. package/README.md +10 -0
  2. package/dist/linux/fake-linux-host.d.ts.map +1 -1
  3. package/dist/linux/fake-systemd.d.ts +15 -0
  4. package/dist/linux/fake-systemd.d.ts.map +1 -1
  5. package/dist/linux/index.js +18 -7
  6. package/dist/linux/index.js.map +5 -5
  7. package/dist/linux/unit-form.d.ts +41 -0
  8. package/dist/linux/unit-form.d.ts.map +1 -1
  9. package/dist/linux/unit-lifecycle.d.ts.map +1 -1
  10. package/dist/linux/unit-settle.d.ts.map +1 -1
  11. package/dist/paperless/client.d.ts +37 -0
  12. package/dist/paperless/client.d.ts.map +1 -0
  13. package/dist/paperless/constraint-guard.d.ts +19 -0
  14. package/dist/paperless/constraint-guard.d.ts.map +1 -0
  15. package/dist/paperless/constraints.d.ts +50 -0
  16. package/dist/paperless/constraints.d.ts.map +1 -0
  17. package/dist/paperless/credentials.d.ts +40 -0
  18. package/dist/paperless/credentials.d.ts.map +1 -0
  19. package/dist/paperless/custom-field.d.ts +75 -0
  20. package/dist/paperless/custom-field.d.ts.map +1 -0
  21. package/dist/paperless/document-type.d.ts +30 -0
  22. package/dist/paperless/document-type.d.ts.map +1 -0
  23. package/dist/paperless/errors.d.ts +49 -0
  24. package/dist/paperless/errors.d.ts.map +1 -0
  25. package/dist/paperless/fake-paperless.d.ts +39 -0
  26. package/dist/paperless/fake-paperless.d.ts.map +1 -0
  27. package/dist/paperless/generated/constraints/index.d.ts +13 -0
  28. package/dist/paperless/generated/constraints/index.d.ts.map +1 -0
  29. package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts +21 -0
  30. package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts.map +1 -0
  31. package/dist/paperless/generated/constraints/paperless-document_types.d.ts +21 -0
  32. package/dist/paperless/generated/constraints/paperless-document_types.d.ts.map +1 -0
  33. package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts +21 -0
  34. package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts.map +1 -0
  35. package/dist/paperless/generated/constraints/paperless-tags.d.ts +21 -0
  36. package/dist/paperless/generated/constraints/paperless-tags.d.ts.map +1 -0
  37. package/dist/paperless/generated/types/custom-fields.d.ts +23 -0
  38. package/dist/paperless/generated/types/custom-fields.d.ts.map +1 -0
  39. package/dist/paperless/generated/types/document-types.d.ts +39 -0
  40. package/dist/paperless/generated/types/document-types.d.ts.map +1 -0
  41. package/dist/paperless/generated/types/index.d.ts +10 -0
  42. package/dist/paperless/generated/types/index.d.ts.map +1 -0
  43. package/dist/paperless/generated/types/storage-paths.d.ts +31 -0
  44. package/dist/paperless/generated/types/storage-paths.d.ts.map +1 -0
  45. package/dist/paperless/generated/types/tags.d.ts +37 -0
  46. package/dist/paperless/generated/types/tags.d.ts.map +1 -0
  47. package/dist/paperless/index.d.ts +19 -0
  48. package/dist/paperless/index.d.ts.map +1 -0
  49. package/dist/paperless/index.js +524 -0
  50. package/dist/paperless/index.js.map +26 -0
  51. package/dist/paperless/matching-locate.d.ts +33 -0
  52. package/dist/paperless/matching-locate.d.ts.map +1 -0
  53. package/dist/paperless/matching-types.d.ts +60 -0
  54. package/dist/paperless/matching-types.d.ts.map +1 -0
  55. package/dist/paperless/matching.d.ts +61 -0
  56. package/dist/paperless/matching.d.ts.map +1 -0
  57. package/dist/paperless/providers.d.ts +15 -0
  58. package/dist/paperless/providers.d.ts.map +1 -0
  59. package/dist/paperless/storage-path.d.ts +32 -0
  60. package/dist/paperless/storage-path.d.ts.map +1 -0
  61. package/dist/paperless/tag.d.ts +47 -0
  62. package/dist/paperless/tag.d.ts.map +1 -0
  63. package/docs/linux-host.md +2 -0
  64. package/docs/paperless.md +115 -0
  65. package/package.json +5 -1
  66. package/src/linux/fake-linux-host.ts +2 -0
  67. package/src/linux/fake-systemd.ts +36 -2
  68. package/src/linux/unit-activating.test.ts +164 -0
  69. package/src/linux/unit-form.ts +51 -0
  70. package/src/linux/unit-lifecycle.ts +9 -2
  71. package/src/linux/unit-settle.ts +8 -4
  72. package/src/paperless/client.ts +88 -0
  73. package/src/paperless/constraint-guard.ts +50 -0
  74. package/src/paperless/constraints.test.ts +74 -0
  75. package/src/paperless/constraints.ts +111 -0
  76. package/src/paperless/credentials.ts +69 -0
  77. package/src/paperless/custom-field.test.ts +74 -0
  78. package/src/paperless/custom-field.ts +95 -0
  79. package/src/paperless/document-type.ts +74 -0
  80. package/src/paperless/errors.ts +62 -0
  81. package/src/paperless/fake-paperless.ts +156 -0
  82. package/src/paperless/generated/constraints/index.ts +23 -0
  83. package/src/paperless/generated/constraints/paperless-custom_fields.ts +29 -0
  84. package/src/paperless/generated/constraints/paperless-document_types.ts +32 -0
  85. package/src/paperless/generated/constraints/paperless-storage_paths.ts +34 -0
  86. package/src/paperless/generated/constraints/paperless-tags.ts +34 -0
  87. package/src/paperless/generated/types/custom-fields.ts +23 -0
  88. package/src/paperless/generated/types/document-types.ts +30 -0
  89. package/src/paperless/generated/types/index.ts +9 -0
  90. package/src/paperless/generated/types/storage-paths.ts +31 -0
  91. package/src/paperless/generated/types/tags.ts +37 -0
  92. package/src/paperless/index.ts +56 -0
  93. package/src/paperless/matching-identity.test.ts +194 -0
  94. package/src/paperless/matching-locate.ts +104 -0
  95. package/src/paperless/matching-types.ts +61 -0
  96. package/src/paperless/matching.test.ts +128 -0
  97. package/src/paperless/matching.ts +239 -0
  98. package/src/paperless/providers.ts +27 -0
  99. package/src/paperless/storage-path.ts +78 -0
  100. 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.24.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=${state?.active === true ? 'active' : 'inactive'}`,
53
- `SubState=${state?.active === true ? 'running' : 'dead'}`,
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
+ });
@@ -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
 
@@ -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
- const active = step.status.activeState === 'active' || step.status.activeState === 'activating';
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 (active) await stopUnit(runner, props.name);
87
- } else if (!active) await startUnit(runner, props.name);
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 !== 'active' && after.activeState !== 'activating') {
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
+ });