@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,74 @@
1
+ /**
2
+ * The generated Paperless constraint tables, exercised at the boundary a plan actually checks:
3
+ * `bodyViolations` against the real generated data, not a literal table constructed by hand.
4
+ *
5
+ * ⛔ THESE TESTS FAIL WITHOUT THE UNIT. Before `codegen/paperless.ts` and `generated/constraints/`
6
+ * existed, `constraintsFor('paperless:POST /api/tags/')` threw "no vendor constraint table" —
7
+ * there was nothing to import.
8
+ */
9
+ import { describe, expect, test } from 'bun:test';
10
+ import { bodyViolations, constraintsFor } from './constraint-guard.ts';
11
+
12
+ const CREATE = 'paperless:POST /api/tags/';
13
+
14
+ describe('a 129-character name is refused, a 128-character one is not', () => {
15
+ test('129 plain characters', () => {
16
+ const found = bodyViolations(CREATE, { name: 'a'.repeat(129) }, true);
17
+ expect(found.some((f) => f.includes('name'))).toBe(true);
18
+ });
19
+
20
+ /** ⚠️ Counts CHARACTERS, not UTF-16 units — an astral emoji is one character to Django. */
21
+ test('128 astral characters (each a surrogate pair) passes the length check', () => {
22
+ const found = bodyViolations(CREATE, { name: '😀'.repeat(128) }, true);
23
+ expect(found.some((f) => f.includes('name'))).toBe(false);
24
+ });
25
+
26
+ test('129 astral characters is refused', () => {
27
+ const found = bodyViolations(CREATE, { name: '😀'.repeat(129) }, true);
28
+ expect(found.some((f) => f.includes('name'))).toBe(true);
29
+ });
30
+ });
31
+
32
+ describe('a 256-plus character match is refused', () => {
33
+ test('256 characters passes, 257 does not', () => {
34
+ expect(bodyViolations(CREATE, { match: 'a'.repeat(256), name: 'x' }, false)).toEqual([]);
35
+ const found = bodyViolations(CREATE, { match: 'a'.repeat(257), name: 'x' }, false);
36
+ expect(found.some((f) => f.includes('match'))).toBe(true);
37
+ });
38
+ });
39
+
40
+ describe('presence is create-only', () => {
41
+ test('a missing required name is refused on create', () => {
42
+ expect(bodyViolations(CREATE, {}, true).some((f) => f.includes('required'))).toBe(true);
43
+ });
44
+
45
+ /** ⚠️ An update is PATCH, deliberately partial — presence must not fire without `presence: true`. */
46
+ test('the same missing name is not refused on update', () => {
47
+ expect(bodyViolations(CREATE, {}, false)).toEqual([]);
48
+ });
49
+ });
50
+
51
+ describe('null is a value, not an absence', () => {
52
+ test('an explicit null owner on a table that carries no owner rule violates nothing', () => {
53
+ expect(bodyViolations(CREATE, { name: 'x', owner: null }, true)).toEqual([]);
54
+ });
55
+ });
56
+
57
+ describe('every generated table is reachable and carries its provenance', () => {
58
+ test('all four create endpoints resolve to a non-empty table', () => {
59
+ for (const key of [
60
+ 'paperless:POST /api/tags/',
61
+ 'paperless:POST /api/document_types/',
62
+ 'paperless:POST /api/storage_paths/',
63
+ 'paperless:POST /api/custom_fields/',
64
+ ]) {
65
+ expect(Object.keys(constraintsFor(key)).length).toBeGreaterThan(0);
66
+ }
67
+ });
68
+
69
+ test('an unknown key names the refresh command rather than silently enforcing nothing', () => {
70
+ expect(() => constraintsFor('paperless:POST /api/never/')).toThrow(
71
+ /bun codegen\/paperless\.ts/,
72
+ );
73
+ });
74
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Paperless-ngx's own rules, checked here, before the request is built.
3
+ *
4
+ * ★ THE SAME READER SHAPE AS `../netbox/constraints.ts`, AND FOR THE SAME REASON: both vendors
5
+ * send a JSON body where a number stays a number and a boolean stays a boolean, so `String(v)`
6
+ * at the door would be the exact mangling that incident (codegen/README.md) exists to prevent.
7
+ * The table itself is generated by `bun codegen/paperless.ts` from the vendor's own OpenAPI
8
+ * document — nothing here is hand-typed.
9
+ *
10
+ * ⚠️ WHAT THIS DELIBERATELY DOES NOT ENFORCE: a `format` name (a DRF validator run server-side); a
11
+ * pattern the generator could not carry faithfully (codegen/py-pattern.ts), recorded verbatim as
12
+ * `patternSource`; the `(name, owner)` uniqueness constraint (models.py) — that is `matching.ts`'s
13
+ * job, not a per-field rule; and `CustomField.select_options`, which is not in the schema at all
14
+ * (groundTruth: the serializer's own rule is unverified at 3.1.1 and this table cannot state a
15
+ * rule the vendor document does not carry).
16
+ */
17
+
18
+ export interface ParamConstraint {
19
+ readonly type?: string;
20
+ /** From the schema's `required` array. ⚠️ Checked on CREATE only. */
21
+ readonly required?: boolean;
22
+ readonly maxLength?: number;
23
+ readonly minLength?: number;
24
+ readonly minimum?: number;
25
+ readonly maximum?: number;
26
+ /** ⚠️ `(string | number)[]` — `matching_algorithm`'s enum is numeric (`DataTypeEnum`'s is not). */
27
+ readonly enum?: readonly (string | number)[];
28
+ readonly pattern?: string;
29
+ readonly patternSource?: string;
30
+ readonly format?: string;
31
+ readonly default?: string;
32
+ }
33
+
34
+ export type EndpointConstraints = Readonly<Record<string, ParamConstraint>>;
35
+
36
+ /** `paperless:POST /api/tags/` — exactly how the generated tables key an endpoint. */
37
+ export type EndpointKey = string;
38
+
39
+ export type PaperlessBody = Record<string, unknown>;
40
+
41
+ export interface ViolationOptions {
42
+ /** ⛔ CREATE ONLY. A PATCH is deliberately partial, so presence there refuses every ordinary edit. */
43
+ readonly presence?: boolean;
44
+ }
45
+
46
+ /** ⚠️ `[...value].length`, NOT `value.length` — Django counts characters, JavaScript UTF-16 units. */
47
+ const lengthOf = (value: string): number => [...value].length;
48
+
49
+ const checkString = (name: string, value: string, rule: ParamConstraint): string[] => {
50
+ const out: string[] = [];
51
+ if (rule.maxLength !== undefined && lengthOf(value) > rule.maxLength) {
52
+ out.push(`${name}: at most ${String(rule.maxLength)} characters`);
53
+ }
54
+ if (rule.minLength !== undefined && lengthOf(value) < rule.minLength) {
55
+ out.push(`${name}: at least ${String(rule.minLength)} characters`);
56
+ }
57
+ if (rule.enum !== undefined && !rule.enum.includes(value)) {
58
+ out.push(`${name}: must be one of ${rule.enum.join(', ')}`);
59
+ }
60
+ if (rule.pattern !== undefined && !new RegExp(rule.pattern).test(value)) {
61
+ out.push(`${name}: must match ${rule.patternSource ?? rule.pattern}`);
62
+ }
63
+ return out;
64
+ };
65
+
66
+ const checkNumber = (name: string, value: number, rule: ParamConstraint): string[] => {
67
+ const out: string[] = [];
68
+ if (!Number.isFinite(value)) return out;
69
+ if (rule.minimum !== undefined && value < rule.minimum)
70
+ out.push(`${name}: at least ${String(rule.minimum)}`);
71
+ if (rule.maximum !== undefined && value > rule.maximum)
72
+ out.push(`${name}: at most ${String(rule.maximum)}`);
73
+ return out;
74
+ };
75
+
76
+ const checkValue = (name: string, value: unknown, rule: ParamConstraint): readonly string[] => {
77
+ if (typeof value === 'string') return checkString(name, value, rule);
78
+ if (typeof value === 'number') return checkNumber(name, value, rule);
79
+ return [];
80
+ };
81
+
82
+ /**
83
+ * Every vendor rule this body breaks. Pure — no network, no clock, no environment.
84
+ *
85
+ * ⚠️ `null` IS A VALUE, NOT AN ABSENCE — Paperless uses explicit `null` to clear a nullable
86
+ * foreign key (`owner`, `parent`), so it must not trip the presence check, and no string/number
87
+ * rule applies to it.
88
+ */
89
+ export const violations = (
90
+ table: EndpointConstraints,
91
+ body: PaperlessBody,
92
+ options: ViolationOptions = {},
93
+ ): readonly string[] => {
94
+ const out: string[] = [];
95
+ for (const [name, rule] of Object.entries(table)) {
96
+ if (!Object.hasOwn(body, name)) {
97
+ if (options.presence === true && rule.required === true) out.push(`${name}: required`);
98
+ continue;
99
+ }
100
+ const value = body[name];
101
+ for (const item of Array.isArray(value) ? value : [value])
102
+ out.push(...checkValue(name, item, rule));
103
+ }
104
+ return out;
105
+ };
106
+
107
+ /** The message a plan dies with. */
108
+ export const refusal = (endpoint: EndpointKey, found: readonly string[]): string =>
109
+ `${endpoint} would be rejected by the vendor:\n - ${found.join('\n - ')}\n` +
110
+ "These are Paperless-ngx's own limits, read from its served OpenAPI document " +
111
+ '(packages/alchemy/src/paperless/generated/constraints). Fix the declaration.';
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Where the Paperless URL and token come from — S24's shape (alchemy-provider-standard): a lazy
3
+ * `Context.Service` whose VALUE is itself an `Effect`, resolved inside each operation with
4
+ * `yield* yield* PaperlessCredentials`. Resolving twice, not once at layer build, is what keeps a
5
+ * token out of any state the engine might persist and lets a consumer swap the source (an env var
6
+ * today, an OpenBao mint tomorrow) without touching a resource file.
7
+ *
8
+ * ⛔ NO DEFAULT HOST, AND NO DEFAULT TOKEN. A published package that fell back to a loopback
9
+ * address would let a consumer who forgot the variable watch every call fail against a machine
10
+ * that is not theirs, instead of being told which variable is missing.
11
+ * ⛔ THE TOKEN IS NEVER A PROP, NEVER AN ATTRIBUTE. Alchemy persists attributes unencrypted
12
+ * (S25); nothing this family returns from `read`/`reconcile` may carry it.
13
+ */
14
+ import * as Context from 'effect/Context';
15
+ import * as Data from 'effect/Data';
16
+ import * as Effect from 'effect/Effect';
17
+ import * as Layer from 'effect/Layer';
18
+
19
+ export class PaperlessCredentialsError extends Data.TaggedError('PaperlessCredentialsError')<{
20
+ readonly message: string;
21
+ }> {}
22
+
23
+ export interface PaperlessCredentialValue {
24
+ /** Trailing slash stripped, e.g. `https://paperless.example.com`. */
25
+ readonly url: string;
26
+ readonly token: string;
27
+ }
28
+
29
+ /**
30
+ * ★ `readEnvironment` NAMES EVERY VARIABLE IT CONSUMES (S24) — `PAPERLESS_URL`, `PAPERLESS_TOKEN`
31
+ * — so a consumer's `alchemy provider check-env` (or an equivalent preflight) can list them.
32
+ */
33
+ const readEnvironment: Effect.Effect<PaperlessCredentialValue, PaperlessCredentialsError> =
34
+ Effect.suspend(() => {
35
+ const url = process.env['PAPERLESS_URL'];
36
+ const token = process.env['PAPERLESS_TOKEN'];
37
+ if (url === undefined || url.trim() === '') {
38
+ return Effect.fail(
39
+ new PaperlessCredentialsError({
40
+ message: 'PAPERLESS_URL is not set. Point it at your Paperless-ngx instance.',
41
+ }),
42
+ );
43
+ }
44
+ if (token === undefined || token.trim() === '') {
45
+ return Effect.fail(
46
+ new PaperlessCredentialsError({
47
+ message:
48
+ 'PAPERLESS_TOKEN is not set. Mint a scoped token and export it — never store it in Alchemy props.',
49
+ }),
50
+ );
51
+ }
52
+ return Effect.succeed({ token: token.trim(), url: url.trim().replace(/\/$/, '') });
53
+ });
54
+
55
+ /**
56
+ * The lazy service. ⚠️ `Layer.succeed` registers the EFFECT, not its result — `readEnvironment`
57
+ * runs once per `yield* yield*`, so a call after the variables are exported sees them even if
58
+ * the layer itself was built before that (true in `fake-paperless.test.ts`, which sets the env
59
+ * vars right before each call).
60
+ */
61
+ export class PaperlessCredentials extends Context.Service<
62
+ PaperlessCredentials,
63
+ Effect.Effect<PaperlessCredentialValue, PaperlessCredentialsError>
64
+ >()('PaperlessCredentials') {}
65
+
66
+ export const environmentLayer: Layer.Layer<PaperlessCredentials> = Layer.succeed(
67
+ PaperlessCredentials,
68
+ readEnvironment,
69
+ );
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `Paperless.CustomField.dataType` is create-only: a change is refused at plan time, never
3
+ * silently PATCHed and never routed to a replace. See custom-field.ts's header for why — a
4
+ * replace would delete the field's value on every document.
5
+ *
6
+ * ⛔ THESE TESTS FAIL WITHOUT THE UNIT. `custom-field.ts` did not exist before this change.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { customFieldHandlers, dataTypeImmutable } from './custom-field.ts';
10
+ import { run, withFake } from './fake-paperless.ts';
11
+
12
+ describe('dataTypeImmutable, the pure rule', () => {
13
+ test('no refusal when the live value is unchanged', () => {
14
+ expect(
15
+ dataTypeImmutable({ data_type: 'string', id: 1 }, { dataType: 'string', name: 'x' }),
16
+ ).toBeUndefined();
17
+ });
18
+
19
+ test('a refusal when the live value differs, naming both values', () => {
20
+ const message = dataTypeImmutable(
21
+ { data_type: 'select', id: 1 },
22
+ { dataType: 'string', name: 'Status' },
23
+ );
24
+ expect(message).toMatch(/data_type is create-only/);
25
+ expect(message).toContain('live: select');
26
+ expect(message).toContain('declared: string');
27
+ });
28
+ });
29
+
30
+ describe('the live lifecycle refuses a data_type change', () => {
31
+ test('reconcile dies rather than PATCHing a changed data_type', async () => {
32
+ await withFake(async (fake) => {
33
+ await run(customFieldHandlers.reconcile({ news: { dataType: 'select', name: 'Status' } }));
34
+ const before = fake.seen.filter((s) => s.method !== 'GET').length;
35
+
36
+ await expect(
37
+ run(customFieldHandlers.reconcile({ news: { dataType: 'string', name: 'Status' } })),
38
+ ).rejects.toThrow(/data_type is create-only/);
39
+
40
+ // ⛔ NOTHING WAS WRITTEN — the refusal fires before the PATCH, not after a failed one.
41
+ expect(fake.seen.filter((s) => s.method !== 'GET').length).toBe(before);
42
+ });
43
+ });
44
+
45
+ test('a create followed by a declaration of the SAME data_type is a normal no-drift reconcile', async () => {
46
+ await withFake(async () => {
47
+ await run(customFieldHandlers.reconcile({ news: { dataType: 'select', name: 'Status' } }));
48
+ const after = await run(
49
+ customFieldHandlers.reconcile({ news: { dataType: 'select', name: 'Status' } }),
50
+ );
51
+ expect(after).toMatchObject({ dataType: 'select', name: 'Status' });
52
+ });
53
+ });
54
+
55
+ test('extraData still updates through a normal PATCH', async () => {
56
+ await withFake(async (fake) => {
57
+ await run(
58
+ customFieldHandlers.reconcile({
59
+ news: { dataType: 'select', extraData: { select_options: ['a'] }, name: 'Status' },
60
+ }),
61
+ );
62
+ await run(
63
+ customFieldHandlers.reconcile({
64
+ news: { dataType: 'select', extraData: { select_options: ['a', 'b'] }, name: 'Status' },
65
+ }),
66
+ );
67
+ const patch = fake.seen.find((s) => s.method === 'PATCH');
68
+ expect(patch).toBeDefined();
69
+ expect(JSON.parse(patch?.body ?? '{}')).toEqual({
70
+ extra_data: { select_options: ['a', 'b'] },
71
+ });
72
+ });
73
+ });
74
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `Paperless.CustomField` — a Paperless-ngx custom field, create-or-update and never deleted.
3
+ *
4
+ * ⛔ `dataType` IS CREATE-ONLY, ENFORCED AT PLAN TIME, NEVER A PATCH AND NEVER A REPLACE. A
5
+ * replace would delete this id and create a new one — and Paperless deletes the field's VALUE
6
+ * on every document when its custom field is deleted. `matching.ts`'s `immutable` hook carries
7
+ * this refusal generically; `dataType` is simply never listed in `fields`, so nothing in
8
+ * `matching.ts` ever builds a PATCH body that includes it.
9
+ *
10
+ * ⛔ NO `owner` FIELD. Unlike `Tag`/`DocumentType`/`StoragePath`, `CustomField` does not extend
11
+ * Paperless-ngx's `OwnedObjectSerializer` — its wire body is exactly `{name, data_type,
12
+ * extra_data}` (measured against the v3.1.1 schema). `owned: false` stops `matching.ts`
13
+ * sending a field the vendor's serializer does not declare.
14
+ *
15
+ * ⚠️ `CustomField.select_options` (the vendor's own rule for a `select` field's option list) is
16
+ * not in the served schema at all, so it is recorded here and NOT enforced (groundTruth).
17
+ */
18
+ import { Resource } from 'alchemy';
19
+ import * as Provider from 'alchemy/Provider';
20
+ import * as Effect from 'effect/Effect';
21
+ import type { PaperlessCustomFieldRequest } from './generated/types/custom-fields.ts';
22
+ import type { PaperlessRow } from './client.ts';
23
+ import { type MatchingRequirements, matchingHandlers } from './matching.ts';
24
+
25
+ export interface CustomFieldProps {
26
+ /** ⚠️ maxLength 128 (generated constraint table). The locate key — CustomField's name is globally unique (models.py:1169), with no owner to further scope it. */
27
+ name: string;
28
+ /**
29
+ * ⛔ CREATE-ONLY — see this file's header. `select`'s own option list
30
+ * (`select_options`) is not in the vendor schema and is unenforced here.
31
+ */
32
+ dataType: PaperlessCustomFieldRequest['data_type'];
33
+ /** Arbitrary JSON the vendor attaches no schema to (e.g. a `select` field's option list). */
34
+ extraData?: unknown;
35
+ }
36
+
37
+ export interface CustomFieldAttributes {
38
+ id: number;
39
+ name: string;
40
+ dataType: string;
41
+ extraData: unknown;
42
+ }
43
+
44
+ export interface CustomField extends Resource<
45
+ 'Paperless.CustomField',
46
+ CustomFieldProps,
47
+ CustomFieldAttributes,
48
+ never,
49
+ MatchingRequirements
50
+ > {}
51
+
52
+ export const CustomField = Resource<CustomField>('Paperless.CustomField', {
53
+ defaultRemovalPolicy: 'retain',
54
+ });
55
+
56
+ /**
57
+ * ⛔ EXTRACTED SO IT CAN BE TESTED WITHOUT A SERVER (custom-field.test.ts) — the same reason
58
+ * `../netbox/prefix-form.ts` pulls its body/matches functions out of `prefix.ts`.
59
+ */
60
+ export const dataTypeImmutable = (
61
+ live: PaperlessRow,
62
+ props: CustomFieldProps,
63
+ ): string | undefined =>
64
+ typeof live['data_type'] === 'string' && live['data_type'] !== props.dataType
65
+ ? `Paperless.CustomField "${props.name}": data_type is create-only (live: ${live['data_type']}, ` +
66
+ `declared: ${props.dataType}). Changing it would delete this field's value on every ` +
67
+ 'document. Create a new custom field instead, and retire this one by hand.'
68
+ : undefined;
69
+
70
+ /** ⚠️ Exported (not through index.ts) so custom-field.test.ts can drive the real lifecycle. */
71
+ export const customFieldHandlers = matchingHandlers<CustomFieldProps, CustomFieldAttributes>({
72
+ attributes: (live, props) => {
73
+ const id = live['id'];
74
+ if (typeof id !== 'number') return undefined;
75
+ return {
76
+ dataType: typeof live['data_type'] === 'string' ? live['data_type'] : props.dataType,
77
+ extraData: live['extra_data'],
78
+ id,
79
+ name: typeof live['name'] === 'string' ? live['name'] : props.name,
80
+ };
81
+ },
82
+ collection: 'custom_fields',
83
+ describe: (props) => `custom_fields ${props.name}`,
84
+ endpoint: {
85
+ create: 'paperless:POST /api/custom_fields/',
86
+ update: 'paperless:PATCH /api/custom_fields/{id}/',
87
+ },
88
+ createOnly: [{ key: 'data_type', live: (r) => r['data_type'], prop: (p) => p.dataType }],
89
+ fields: [{ key: 'extra_data', live: (r) => r['extra_data'], prop: (p) => p.extraData }],
90
+ immutable: dataTypeImmutable,
91
+ owned: false,
92
+ });
93
+
94
+ export const CustomFieldProvider = () =>
95
+ Provider.effect(CustomField, Effect.succeed(CustomField.Provider.of(customFieldHandlers)));
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `Paperless.DocumentType` — a Paperless-ngx document type, create-or-update and never deleted.
3
+ * Same shape as `tag.ts` minus `color`/`isInboxTag`/`parent` — see that file's header for why
4
+ * `matchingAlgorithm`'s type comes off the generated request type rather than being retyped.
5
+ */
6
+ import { Resource } from 'alchemy';
7
+ import * as Provider from 'alchemy/Provider';
8
+ import * as Effect from 'effect/Effect';
9
+ import type { PaperlessDocumentTypeRequest } from './generated/types/document-types.ts';
10
+ import { type MatchingProps, type MatchingRequirements, matchingHandlers } from './matching.ts';
11
+
12
+ export interface DocumentTypeProps extends MatchingProps {
13
+ /** ⚠️ maxLength 128 (generated constraint table). The locate key. */
14
+ name: string;
15
+ /** ⚠️ maxLength 256. */
16
+ match?: string;
17
+ matchingAlgorithm?: PaperlessDocumentTypeRequest['matching_algorithm'];
18
+ isInsensitive?: boolean;
19
+ }
20
+
21
+ export interface DocumentTypeAttributes {
22
+ id: number;
23
+ name: string;
24
+ match: string;
25
+ matchingAlgorithm: number;
26
+ isInsensitive: boolean;
27
+ owner: number | undefined;
28
+ }
29
+
30
+ export interface DocumentType extends Resource<
31
+ 'Paperless.DocumentType',
32
+ DocumentTypeProps,
33
+ DocumentTypeAttributes,
34
+ never,
35
+ MatchingRequirements
36
+ > {}
37
+
38
+ export const DocumentType = Resource<DocumentType>('Paperless.DocumentType', {
39
+ defaultRemovalPolicy: 'retain',
40
+ });
41
+
42
+ const handlers = matchingHandlers<DocumentTypeProps, DocumentTypeAttributes>({
43
+ attributes: (live, props) => {
44
+ const id = live['id'];
45
+ if (typeof id !== 'number') return undefined;
46
+ return {
47
+ id,
48
+ isInsensitive: live['is_insensitive'] === true,
49
+ match: typeof live['match'] === 'string' ? live['match'] : '',
50
+ matchingAlgorithm:
51
+ typeof live['matching_algorithm'] === 'number' ? live['matching_algorithm'] : 0,
52
+ name: typeof live['name'] === 'string' ? live['name'] : props.name,
53
+ owner: typeof live['owner'] === 'number' ? live['owner'] : undefined,
54
+ };
55
+ },
56
+ collection: 'document_types',
57
+ describe: (props) => `document_types ${props.name}`,
58
+ endpoint: {
59
+ create: 'paperless:POST /api/document_types/',
60
+ update: 'paperless:PATCH /api/document_types/{id}/',
61
+ },
62
+ fields: [
63
+ { key: 'match', live: (r) => r['match'], prop: (p) => p.match },
64
+ {
65
+ key: 'matching_algorithm',
66
+ live: (r) => r['matching_algorithm'],
67
+ prop: (p) => p.matchingAlgorithm,
68
+ },
69
+ { key: 'is_insensitive', live: (r) => r['is_insensitive'], prop: (p) => p.isInsensitive },
70
+ ],
71
+ });
72
+
73
+ export const DocumentTypeProvider = () =>
74
+ Provider.effect(DocumentType, Effect.succeed(DocumentType.Provider.of(handlers)));
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Paperless-ngx's HTTP status codes, mapped to tagged errors exactly once — here, and never
3
+ * again by a status check inside a resource (S21, alchemy-provider-standard).
4
+ *
5
+ * ⛔ A SINGLE `PaperlessError` CLASS WITH A `.status` FIELD WOULD WORK, AND IS WHAT `netbox/
6
+ * client.ts` DOES. This family does it the S21 way instead: one tag per distinct MEANING, so a
7
+ * caller writes `Effect.catchTag('PaperlessNotFound', …)` rather than `if (e.status === 404)`.
8
+ * `matching.ts`'s "a 403 is never read as absent" rule is exactly what a shared tag buys —
9
+ * `catchTag('PaperlessNotFound', …)` cannot accidentally also catch `PaperlessUnauthorized`.
10
+ */
11
+ import * as Data from 'effect/Data';
12
+
13
+ /** No object at that path. The only tag `matching.ts` folds to "absent". */
14
+ export class PaperlessNotFound extends Data.TaggedError('PaperlessNotFound')<{
15
+ readonly method: string;
16
+ readonly path: string;
17
+ }> {}
18
+
19
+ /** 401 (no/bad token) or 403 (token valid, forbidden) — NEVER read as "the object is absent". */
20
+ export class PaperlessUnauthorized extends Data.TaggedError('PaperlessUnauthorized')<{
21
+ readonly status: 401 | 403;
22
+ readonly method: string;
23
+ readonly path: string;
24
+ }> {}
25
+
26
+ /** 400 — the vendor rejected the body. Carries it, so the message quotes Paperless's own words. */
27
+ export class PaperlessValidation extends Data.TaggedError('PaperlessValidation')<{
28
+ readonly method: string;
29
+ readonly path: string;
30
+ readonly body: string;
31
+ }> {}
32
+
33
+ /** 5xx, or a status this family's endpoints do not otherwise document. */
34
+ export class PaperlessUnavailable extends Data.TaggedError('PaperlessUnavailable')<{
35
+ readonly status: number;
36
+ readonly method: string;
37
+ readonly path: string;
38
+ readonly detail: string;
39
+ }> {}
40
+
41
+ export type PaperlessError =
42
+ | PaperlessNotFound
43
+ | PaperlessUnauthorized
44
+ | PaperlessValidation
45
+ | PaperlessUnavailable;
46
+
47
+ /**
48
+ * The one place a status code is read. ⚠️ Anything outside {400,401,403,404,5xx} — 405, 409, 429
49
+ * — is not documented for these four endpoints, so it falls to `PaperlessValidation` rather than
50
+ * inventing a fifth tag for a case that was never measured.
51
+ */
52
+ export const statusToError = (
53
+ status: number,
54
+ method: string,
55
+ path: string,
56
+ body: string,
57
+ ): PaperlessError => {
58
+ if (status === 404) return new PaperlessNotFound({ method, path });
59
+ if (status === 401 || status === 403) return new PaperlessUnauthorized({ method, path, status });
60
+ if (status >= 500) return new PaperlessUnavailable({ detail: body, method, path, status });
61
+ return new PaperlessValidation({ body, method, path });
62
+ };