@homeflare/alchemy 0.23.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.
Files changed (177) hide show
  1. package/README.md +25 -1
  2. package/dist/litellm/client.d.ts +76 -0
  3. package/dist/litellm/client.d.ts.map +1 -0
  4. package/dist/litellm/credentials.d.ts +48 -0
  5. package/dist/litellm/credentials.d.ts.map +1 -0
  6. package/dist/litellm/fake-litellm.d.ts +36 -0
  7. package/dist/litellm/fake-litellm.d.ts.map +1 -0
  8. package/dist/litellm/fake-stack.d.ts +30 -0
  9. package/dist/litellm/fake-stack.d.ts.map +1 -0
  10. package/dist/litellm/generated/pass-through.d.ts +71 -0
  11. package/dist/litellm/generated/pass-through.d.ts.map +1 -0
  12. package/dist/litellm/index.d.ts +13 -0
  13. package/dist/litellm/index.d.ts.map +1 -0
  14. package/dist/litellm/index.js +270 -0
  15. package/dist/litellm/index.js.map +14 -0
  16. package/dist/litellm/pass-through-endpoint.d.ts +94 -0
  17. package/dist/litellm/pass-through-endpoint.d.ts.map +1 -0
  18. package/dist/litellm/pass-through-form.d.ts +40 -0
  19. package/dist/litellm/pass-through-form.d.ts.map +1 -0
  20. package/dist/litellm/providers.d.ts +16 -0
  21. package/dist/litellm/providers.d.ts.map +1 -0
  22. package/dist/paperless/client.d.ts +37 -0
  23. package/dist/paperless/client.d.ts.map +1 -0
  24. package/dist/paperless/constraint-guard.d.ts +19 -0
  25. package/dist/paperless/constraint-guard.d.ts.map +1 -0
  26. package/dist/paperless/constraints.d.ts +50 -0
  27. package/dist/paperless/constraints.d.ts.map +1 -0
  28. package/dist/paperless/credentials.d.ts +40 -0
  29. package/dist/paperless/credentials.d.ts.map +1 -0
  30. package/dist/paperless/custom-field.d.ts +75 -0
  31. package/dist/paperless/custom-field.d.ts.map +1 -0
  32. package/dist/paperless/document-type.d.ts +30 -0
  33. package/dist/paperless/document-type.d.ts.map +1 -0
  34. package/dist/paperless/errors.d.ts +49 -0
  35. package/dist/paperless/errors.d.ts.map +1 -0
  36. package/dist/paperless/fake-paperless.d.ts +39 -0
  37. package/dist/paperless/fake-paperless.d.ts.map +1 -0
  38. package/dist/paperless/generated/constraints/index.d.ts +13 -0
  39. package/dist/paperless/generated/constraints/index.d.ts.map +1 -0
  40. package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts +21 -0
  41. package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts.map +1 -0
  42. package/dist/paperless/generated/constraints/paperless-document_types.d.ts +21 -0
  43. package/dist/paperless/generated/constraints/paperless-document_types.d.ts.map +1 -0
  44. package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts +21 -0
  45. package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts.map +1 -0
  46. package/dist/paperless/generated/constraints/paperless-tags.d.ts +21 -0
  47. package/dist/paperless/generated/constraints/paperless-tags.d.ts.map +1 -0
  48. package/dist/paperless/generated/types/custom-fields.d.ts +23 -0
  49. package/dist/paperless/generated/types/custom-fields.d.ts.map +1 -0
  50. package/dist/paperless/generated/types/document-types.d.ts +39 -0
  51. package/dist/paperless/generated/types/document-types.d.ts.map +1 -0
  52. package/dist/paperless/generated/types/index.d.ts +10 -0
  53. package/dist/paperless/generated/types/index.d.ts.map +1 -0
  54. package/dist/paperless/generated/types/storage-paths.d.ts +31 -0
  55. package/dist/paperless/generated/types/storage-paths.d.ts.map +1 -0
  56. package/dist/paperless/generated/types/tags.d.ts +37 -0
  57. package/dist/paperless/generated/types/tags.d.ts.map +1 -0
  58. package/dist/paperless/index.d.ts +19 -0
  59. package/dist/paperless/index.d.ts.map +1 -0
  60. package/dist/paperless/index.js +524 -0
  61. package/dist/paperless/index.js.map +26 -0
  62. package/dist/paperless/matching-locate.d.ts +33 -0
  63. package/dist/paperless/matching-locate.d.ts.map +1 -0
  64. package/dist/paperless/matching-types.d.ts +60 -0
  65. package/dist/paperless/matching-types.d.ts.map +1 -0
  66. package/dist/paperless/matching.d.ts +61 -0
  67. package/dist/paperless/matching.d.ts.map +1 -0
  68. package/dist/paperless/providers.d.ts +15 -0
  69. package/dist/paperless/providers.d.ts.map +1 -0
  70. package/dist/paperless/storage-path.d.ts +32 -0
  71. package/dist/paperless/storage-path.d.ts.map +1 -0
  72. package/dist/paperless/tag.d.ts +47 -0
  73. package/dist/paperless/tag.d.ts.map +1 -0
  74. package/dist/postgres/connection.d.ts +50 -0
  75. package/dist/postgres/connection.d.ts.map +1 -0
  76. package/dist/postgres/database-attrs.d.ts +79 -0
  77. package/dist/postgres/database-attrs.d.ts.map +1 -0
  78. package/dist/postgres/database-sql.d.ts +73 -0
  79. package/dist/postgres/database-sql.d.ts.map +1 -0
  80. package/dist/postgres/database.d.ts +63 -0
  81. package/dist/postgres/database.d.ts.map +1 -0
  82. package/dist/postgres/errors.d.ts +86 -0
  83. package/dist/postgres/errors.d.ts.map +1 -0
  84. package/dist/postgres/fake-sql.d.ts +22 -0
  85. package/dist/postgres/fake-sql.d.ts.map +1 -0
  86. package/dist/postgres/index.d.ts +16 -0
  87. package/dist/postgres/index.d.ts.map +1 -0
  88. package/dist/postgres/index.js +238 -0
  89. package/dist/postgres/index.js.map +15 -0
  90. package/dist/postgres/providers.d.ts +14 -0
  91. package/dist/postgres/providers.d.ts.map +1 -0
  92. package/dist/proxmox/index.js.map +2 -2
  93. package/dist/proxmox/zfs-pool.d.ts +10 -2
  94. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  95. package/dist/release/archive.d.ts +6 -2
  96. package/dist/release/archive.d.ts.map +1 -1
  97. package/dist/release/binary-form.d.ts +14 -9
  98. package/dist/release/binary-form.d.ts.map +1 -1
  99. package/dist/release/binary-pins.d.ts +17 -0
  100. package/dist/release/binary-pins.d.ts.map +1 -0
  101. package/dist/release/catalog.d.ts.map +1 -1
  102. package/dist/release/index.js +90 -40
  103. package/dist/release/index.js.map +8 -7
  104. package/dist/release/tar.d.ts +9 -2
  105. package/dist/release/tar.d.ts.map +1 -1
  106. package/docs/litellm.md +110 -0
  107. package/docs/paperless.md +115 -0
  108. package/docs/peers.md +8 -1
  109. package/docs/postgres.md +125 -0
  110. package/docs/release-binary-catalogs.md +40 -4
  111. package/docs/release-binary-refusals.md +22 -8
  112. package/docs/release-binary-upstream.md +33 -15
  113. package/package.json +14 -1
  114. package/src/litellm/client.ts +195 -0
  115. package/src/litellm/credentials.ts +73 -0
  116. package/src/litellm/fake-litellm.ts +114 -0
  117. package/src/litellm/fake-stack.ts +100 -0
  118. package/src/litellm/generated/pass-through.ts +73 -0
  119. package/src/litellm/index.ts +42 -0
  120. package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +66 -0
  121. package/src/litellm/pass-through-endpoint-replace.test.ts +84 -0
  122. package/src/litellm/pass-through-endpoint.test.ts +208 -0
  123. package/src/litellm/pass-through-endpoint.ts +258 -0
  124. package/src/litellm/pass-through-form.test.ts +128 -0
  125. package/src/litellm/pass-through-form.ts +136 -0
  126. package/src/litellm/providers.ts +19 -0
  127. package/src/paperless/client.ts +88 -0
  128. package/src/paperless/constraint-guard.ts +50 -0
  129. package/src/paperless/constraints.test.ts +74 -0
  130. package/src/paperless/constraints.ts +111 -0
  131. package/src/paperless/credentials.ts +69 -0
  132. package/src/paperless/custom-field.test.ts +74 -0
  133. package/src/paperless/custom-field.ts +95 -0
  134. package/src/paperless/document-type.ts +74 -0
  135. package/src/paperless/errors.ts +62 -0
  136. package/src/paperless/fake-paperless.ts +156 -0
  137. package/src/paperless/generated/constraints/index.ts +23 -0
  138. package/src/paperless/generated/constraints/paperless-custom_fields.ts +29 -0
  139. package/src/paperless/generated/constraints/paperless-document_types.ts +32 -0
  140. package/src/paperless/generated/constraints/paperless-storage_paths.ts +34 -0
  141. package/src/paperless/generated/constraints/paperless-tags.ts +34 -0
  142. package/src/paperless/generated/types/custom-fields.ts +23 -0
  143. package/src/paperless/generated/types/document-types.ts +30 -0
  144. package/src/paperless/generated/types/index.ts +9 -0
  145. package/src/paperless/generated/types/storage-paths.ts +31 -0
  146. package/src/paperless/generated/types/tags.ts +37 -0
  147. package/src/paperless/index.ts +56 -0
  148. package/src/paperless/matching-identity.test.ts +194 -0
  149. package/src/paperless/matching-locate.ts +104 -0
  150. package/src/paperless/matching-types.ts +61 -0
  151. package/src/paperless/matching.test.ts +128 -0
  152. package/src/paperless/matching.ts +239 -0
  153. package/src/paperless/providers.ts +27 -0
  154. package/src/paperless/storage-path.ts +78 -0
  155. package/src/paperless/tag.ts +91 -0
  156. package/src/postgres/connection.ts +62 -0
  157. package/src/postgres/database-attrs.ts +87 -0
  158. package/src/postgres/database-bigint-serialization.test.ts +140 -0
  159. package/src/postgres/database-lifecycle.test.ts +113 -0
  160. package/src/postgres/database-provenance.test.ts +118 -0
  161. package/src/postgres/database-refusals.test.ts +135 -0
  162. package/src/postgres/database-sql.ts +174 -0
  163. package/src/postgres/database.ts +193 -0
  164. package/src/postgres/errors.ts +131 -0
  165. package/src/postgres/fake-sql.ts +123 -0
  166. package/src/postgres/fixtures/pg-REL_18_6-create-database-synopsis.txt +26 -0
  167. package/src/postgres/fixtures/pg-REL_18_6-pg_database.h.txt +67 -0
  168. package/src/postgres/index.ts +30 -0
  169. package/src/postgres/providers.ts +16 -0
  170. package/src/proxmox/zfs-pool.ts +10 -2
  171. package/src/release/archive.ts +10 -4
  172. package/src/release/binary-form.ts +24 -76
  173. package/src/release/binary-pins.ts +95 -0
  174. package/src/release/binary-root.test.ts +122 -0
  175. package/src/release/catalog.ts +10 -2
  176. package/src/release/tar-root.test.ts +114 -0
  177. package/src/release/tar.ts +78 -12
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The wire body for a `LiteLLM.PassThroughEndpoint`, the comparison that decides whether it needs
3
+ * writing, and the refusals that stop a plan before it writes anything unsafe.
4
+ *
5
+ * ★ EXTRACTED SO IT CAN BE TESTED WITHOUT A SERVER — the same reason netbox keeps `prefix-form.ts`
6
+ * beside `prefix.ts` (S32/testing-and-docs.md).
7
+ *
8
+ * ⛔ PROPS ARE THE GENERATED TYPE, MINUS THE TWO FIELDS THIS RESOURCE OWNS ITSELF: `id` (a
9
+ * deterministic physical name — pass-through-endpoint.ts) and `is_from_config` (response-only;
10
+ * the vendor sets it, a declaration never does). Deriving `Props` with `Omit<>` rather than
11
+ * hand-typing it means a vendor schema change that adds, removes or narrows a field is felt here
12
+ * as a compiler error, not silently ignored.
13
+ */
14
+ import { deepEqual } from 'alchemy/Diff';
15
+ import type * as Generated from './generated/pass-through.ts';
16
+
17
+ export type PassThroughEndpointProps = Omit<
18
+ Generated.PassThroughGenericEndpoint,
19
+ 'id' | 'is_from_config'
20
+ >;
21
+
22
+ /** What `read` returns: the props plus the two vendor-owned fields, `id` now guaranteed present. */
23
+ export type PassThroughEndpointAttributes = Omit<Generated.PassThroughGenericEndpoint, 'id'> & {
24
+ readonly id: string;
25
+ };
26
+
27
+ /** ⚠️ NetBox `settled()`'s pattern: only fields the SCHEMA gives a vendor default for, copied from it (not assumed). */
28
+ export const settled = (props: PassThroughEndpointProps) => ({
29
+ auth: props.auth ?? true,
30
+ cost_per_request: props.cost_per_request ?? 0,
31
+ default_query_params: props.default_query_params ?? {},
32
+ headers: props.headers ?? {},
33
+ include_subpath: props.include_subpath ?? false,
34
+ });
35
+
36
+ /** The three fields whose absence the vendor's `exclude_none` update can never write (see client.ts). */
37
+ export const NULLABLE_FIELDS = ['timeout', 'methods', 'guardrails'] as const;
38
+
39
+ const isUnset = (value: unknown): boolean => value === undefined || value === null;
40
+
41
+ /**
42
+ * ⚠️ BUILT AS A WIDER RECORD AND PRUNED, NOT AS THE GENERATED TYPE DIRECTLY.
43
+ * `exactOptionalPropertyTypes` is on (packages/config/tsconfig.base.json), so writing
44
+ * `timeout: undefined` into a `{ timeout?: number | null }` is a type error rather than an
45
+ * absent key — the same reason `codegen/openapi.ts`'s `prune` exists, and the same fix.
46
+ */
47
+ const prune = (value: Record<string, unknown>): Record<string, unknown> =>
48
+ Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
49
+
50
+ /**
51
+ * Full body for CREATE. `id` is the deterministic physical name (pass-through-endpoint.ts), sent
52
+ * so a failed create is recoverable by `read` rather than orphaning a second row on retry.
53
+ */
54
+ export const createBody = (
55
+ props: PassThroughEndpointProps,
56
+ id: string,
57
+ ): Generated.PassThroughGenericEndpoint =>
58
+ prune({
59
+ ...settled(props),
60
+ guardrails: isUnset(props.guardrails) ? undefined : props.guardrails,
61
+ id,
62
+ methods: isUnset(props.methods) ? undefined : props.methods,
63
+ path: props.path,
64
+ target: props.target,
65
+ timeout: isUnset(props.timeout) ? undefined : props.timeout,
66
+ }) as unknown as Generated.PassThroughGenericEndpoint;
67
+
68
+ /**
69
+ * Partial body for UPDATE (`POST /{id}`). ⛔ ONLY FIELDS THIS DECLARATION ACTUALLY SETS — the
70
+ * vendor merges the parsed body with `model_dump(exclude_none=True)` (measured at v1.100.0), so an
71
+ * `undefined` OR an explicit `null` here would be dropped by the server anyway; sending neither is
72
+ * the same outcome with no request made when nothing changed (`Object.keys(body).length === 0`
73
+ * checked by the caller in pass-through-endpoint.ts).
74
+ */
75
+ export const updateBody = (
76
+ props: PassThroughEndpointProps,
77
+ ): Partial<Generated.PassThroughGenericEndpoint> =>
78
+ prune({
79
+ ...settled(props),
80
+ guardrails: isUnset(props.guardrails) ? undefined : props.guardrails,
81
+ methods: isUnset(props.methods) ? undefined : props.methods,
82
+ path: props.path,
83
+ target: props.target,
84
+ timeout: isUnset(props.timeout) ? undefined : props.timeout,
85
+ }) as Partial<Generated.PassThroughGenericEndpoint>;
86
+
87
+ /**
88
+ * Whether ANY of the three nullable fields went from set (in `live`) to unset (in `props`) — the
89
+ * one case `updateBody` cannot express, because the vendor's `exclude_none` merge silently keeps
90
+ * the old value instead of clearing it. Detected here so `pass-through-endpoint.ts` can plan a
91
+ * `replace` (delete-then-create) instead of an update that would look like it worked.
92
+ */
93
+ export const needsReplace = (
94
+ live: PassThroughEndpointAttributes,
95
+ props: PassThroughEndpointProps,
96
+ ): boolean => NULLABLE_FIELDS.some((field) => !isUnset(live[field]) && isUnset(props[field]));
97
+
98
+ /** Whether the live object already says what the declaration says — the `diff` "noop" test. */
99
+ export const matches = (
100
+ live: PassThroughEndpointAttributes,
101
+ props: PassThroughEndpointProps,
102
+ ): boolean => {
103
+ const fixed = settled(props);
104
+ return (
105
+ live.target === props.target &&
106
+ live.auth === fixed.auth &&
107
+ live.cost_per_request === fixed.cost_per_request &&
108
+ live.include_subpath === fixed.include_subpath &&
109
+ deepEqual(live.headers, fixed.headers) &&
110
+ deepEqual(live.default_query_params, fixed.default_query_params) &&
111
+ (isUnset(props.timeout) ? isUnset(live.timeout) : live.timeout === props.timeout) &&
112
+ (isUnset(props.methods) ? isUnset(live.methods) : deepEqual(live.methods, props.methods)) &&
113
+ (isUnset(props.guardrails)
114
+ ? isUnset(live.guardrails)
115
+ : deepEqual(live.guardrails, props.guardrails))
116
+ );
117
+ };
118
+
119
+ /**
120
+ * A literal secret in a forwarded header (S25, house rule — not a vendor one; see
121
+ * pass-through-endpoint.ts). Case-insensitive on the header name; the reference form is LiteLLM's
122
+ * own, `os.environ/NAME` (`set_env_variables_in_header`, pass_through_endpoints.py:117-160 at the
123
+ * tag) — a missing variable on the proxy host leaves that literal string in the header, which
124
+ * nothing in the API can detect, so this is enforced here rather than trusted to the vendor.
125
+ */
126
+ const SECRET_HEADER_NAMES = new Set(['authorization', 'x-api-key', 'cf-aig-authorization']);
127
+
128
+ export const literalSecretHeaders = (
129
+ headers: Readonly<Record<string, unknown>> | undefined,
130
+ ): readonly string[] =>
131
+ Object.entries(headers ?? {})
132
+ .filter(
133
+ ([name, value]) =>
134
+ SECRET_HEADER_NAMES.has(name.toLowerCase()) && !String(value).includes('os.environ/'),
135
+ )
136
+ .map(([name]) => name);
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The LiteLLM provider, with its credential source, as one layer for a stack.
3
+ *
4
+ * Layer.mergeAll(litellmProviders(), …the stack's other providers)
5
+ *
6
+ * ★ THE DEFAULT READS `LITELLM_PROXY_URL`/`LITELLM_PROXY_API_KEY` AT CALL TIME (credentials.ts),
7
+ * never at layer build — pass another `Layer.Layer<LitellmCredentials>` (see
8
+ * `litellmCredentialsLayerFor` in a test) to source them another way.
9
+ * ⚠️ `HttpClient` is NOT provided here. It is ambient in every Alchemy runtime the same way it is
10
+ * for `netboxProviders`-style families in this kit; a stack that already deploys anything over
11
+ * HTTP already has one.
12
+ */
13
+ import * as Layer from 'effect/Layer';
14
+ import { type LitellmCredentials, litellmCredentialsLayer } from './credentials.ts';
15
+ import { LiteLLMPassThroughEndpointProvider } from './pass-through-endpoint.ts';
16
+
17
+ export const litellmProviders = (
18
+ creds: Layer.Layer<LitellmCredentials> = litellmCredentialsLayer,
19
+ ) => LiteLLMPassThroughEndpointProvider().pipe(Layer.provide(creds));
@@ -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
+ });