@homeflare/alchemy 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- 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/paperless.md +115 -0
- package/package.json +5 -1
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
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"
|
|
@@ -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
|
+
});
|
|
@@ -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)));
|