@homeflare/alchemy 0.28.0 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/caddy/admin-address.d.ts +27 -0
- package/dist/caddy/admin-address.d.ts.map +1 -0
- package/dist/caddy/admin-calls.d.ts +25 -28
- package/dist/caddy/admin-calls.d.ts.map +1 -1
- package/dist/caddy/admin.d.ts +28 -38
- package/dist/caddy/admin.d.ts.map +1 -1
- package/dist/caddy/caddy-http-client.d.ts +25 -0
- package/dist/caddy/caddy-http-client.d.ts.map +1 -0
- package/dist/caddy/config-errors.d.ts +24 -0
- package/dist/caddy/config-errors.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +20 -49
- package/dist/caddy/config-lifecycle.d.ts.map +1 -1
- package/dist/caddy/config-reconcile.d.ts +45 -0
- package/dist/caddy/config-reconcile.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +4 -3
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +10 -2
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.d.ts +3 -3
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +487 -444
- package/dist/caddy/index.js.map +16 -12
- package/dist/caddy/local-admin.d.ts +20 -34
- package/dist/caddy/local-admin.d.ts.map +1 -1
- package/dist/caddy/providers.d.ts +19 -3
- package/dist/caddy/providers.d.ts.map +1 -1
- package/dist/cloudflare/mesh-node-token.d.ts +1 -1
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -1
- package/dist/forgejo/branch-protection.d.ts +4 -4
- package/dist/forgejo/org-actions-secrets.d.ts +4 -4
- package/dist/forgejo/org-label.d.ts +4 -4
- package/dist/forgejo/org-team.d.ts +4 -4
- package/dist/forgejo/repo-webhook.d.ts +4 -4
- package/dist/forgejo/repository.d.ts +4 -4
- package/dist/forgejo/team-member.d.ts +4 -4
- package/dist/github/declare-repo-baseline.d.ts +1 -1
- package/dist/github/declare-repo-baseline.d.ts.map +1 -1
- package/dist/github/index.d.ts +8 -4
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +83 -24
- package/dist/github/index.js.map +14 -12
- package/dist/github/repo-apply-order-fake-providers.d.ts +48 -0
- package/dist/github/repo-apply-order-fake-providers.d.ts.map +1 -0
- package/dist/github/repo-apply-order-fake-stack.d.ts +13 -0
- package/dist/github/repo-apply-order-fake-stack.d.ts.map +1 -0
- package/dist/github/repo-auto-merge-gate.d.ts +54 -0
- package/dist/github/repo-auto-merge-gate.d.ts.map +1 -0
- package/dist/github/repo-baseline-data.d.ts +10 -1
- package/dist/github/repo-baseline-data.d.ts.map +1 -1
- package/dist/github/repo-policy.d.ts +1 -1
- package/dist/github/repo-policy.d.ts.map +1 -1
- package/dist/github/repository-ruleset-errors.d.ts +3 -2
- package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
- package/dist/github/repository-ruleset-form.d.ts.map +1 -1
- package/dist/github/repository-ruleset-guards.d.ts +3 -2
- package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
- package/dist/github/repository-ruleset-octokit.d.ts +49 -0
- package/dist/github/repository-ruleset-octokit.d.ts.map +1 -1
- package/dist/github/repository-ruleset-probe.d.ts +2 -1
- package/dist/github/repository-ruleset-probe.d.ts.map +1 -1
- package/dist/github/repository-ruleset-props.d.ts +7 -4
- package/dist/github/repository-ruleset-props.d.ts.map +1 -1
- package/dist/github/repository-ruleset-providers.d.ts +42 -0
- package/dist/github/repository-ruleset-providers.d.ts.map +1 -0
- package/dist/github/repository-ruleset.d.ts +26 -4
- package/dist/github/repository-ruleset.d.ts.map +1 -1
- package/dist/litellm/fake-litellm.d.ts +27 -16
- package/dist/litellm/fake-litellm.d.ts.map +1 -1
- package/dist/litellm/fake-stack.d.ts +9 -4
- package/dist/litellm/fake-stack.d.ts.map +1 -1
- package/dist/litellm/index.d.ts +7 -5
- package/dist/litellm/index.d.ts.map +1 -1
- package/dist/litellm/index.js +53 -123
- package/dist/litellm/index.js.map +8 -8
- package/dist/litellm/operations.d.ts +60 -0
- package/dist/litellm/operations.d.ts.map +1 -0
- package/dist/litellm/pass-through-endpoint.d.ts +10 -28
- package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
- package/dist/litellm/pass-through-errors.d.ts +37 -0
- package/dist/litellm/pass-through-errors.d.ts.map +1 -0
- package/dist/litellm/pass-through-form.d.ts +11 -4
- package/dist/litellm/pass-through-form.d.ts.map +1 -1
- package/dist/litellm/providers.d.ts +7 -5
- package/dist/litellm/providers.d.ts.map +1 -1
- package/dist/netbox/prefix.d.ts +4 -4
- package/dist/paperless/custom-field.d.ts +21 -21
- package/dist/paperless/custom-field.d.ts.map +1 -1
- package/dist/paperless/document-type.d.ts +34 -4
- package/dist/paperless/document-type.d.ts.map +1 -1
- package/dist/paperless/fake-paperless.d.ts +36 -19
- package/dist/paperless/fake-paperless.d.ts.map +1 -1
- package/dist/paperless/index.d.ts +7 -2
- package/dist/paperless/index.d.ts.map +1 -1
- package/dist/paperless/index.js +164 -228
- package/dist/paperless/index.js.map +10 -13
- package/dist/paperless/matching-locate.d.ts +42 -20
- package/dist/paperless/matching-locate.d.ts.map +1 -1
- package/dist/paperless/matching-types.d.ts +47 -18
- package/dist/paperless/matching-types.d.ts.map +1 -1
- package/dist/paperless/matching.d.ts +30 -27
- package/dist/paperless/matching.d.ts.map +1 -1
- package/dist/paperless/providers.d.ts +13 -7
- package/dist/paperless/providers.d.ts.map +1 -1
- package/dist/paperless/storage-path.d.ts +34 -4
- package/dist/paperless/storage-path.d.ts.map +1 -1
- package/dist/paperless/tag.d.ts +43 -8
- package/dist/paperless/tag.d.ts.map +1 -1
- package/dist/proxmox/acl.d.ts +30 -23
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/distilled-pve.d.ts +89 -0
- package/dist/proxmox/distilled-pve.d.ts.map +1 -0
- package/dist/proxmox/index.js +663 -609
- package/dist/proxmox/index.js.map +12 -11
- package/dist/proxmox/members.d.ts +29 -10
- package/dist/proxmox/members.d.ts.map +1 -1
- package/docs/caddy-admin.md +1 -1
- package/docs/caddy.md +3 -2
- package/docs/credentials.md +16 -0
- package/docs/litellm.md +21 -13
- package/docs/paperless.md +12 -0
- package/docs/peers.md +5 -0
- package/docs/repo-policy.md +16 -1
- package/docs/repository-ruleset.md +53 -13
- package/docs/upstream-conformance.md +126 -10
- package/package.json +5 -2
- package/src/caddy/admin-address.ts +71 -0
- package/src/caddy/admin-calls.ts +48 -100
- package/src/caddy/admin.ts +37 -41
- package/src/caddy/adopt-scope.test.ts +1 -1
- package/src/caddy/caddy-http-client.ts +229 -0
- package/src/caddy/config-adopt-provider.test.ts +139 -0
- package/src/caddy/config-adopt.test.ts +60 -126
- package/src/caddy/config-errors.ts +47 -0
- package/src/caddy/config-lifecycle.test.ts +55 -42
- package/src/caddy/config-lifecycle.ts +72 -162
- package/src/caddy/config-reconcile.ts +138 -0
- package/src/caddy/config.ts +36 -42
- package/src/caddy/fake-caddy.ts +20 -2
- package/src/caddy/index.ts +3 -8
- package/src/caddy/local-admin.test.ts +35 -21
- package/src/caddy/local-admin.ts +60 -167
- package/src/caddy/providers.test.ts +14 -6
- package/src/caddy/providers.ts +20 -4
- package/src/github/declare-repo-baseline-apply-order.test.ts +86 -0
- package/src/github/declare-repo-baseline.ts +40 -7
- package/src/github/index.ts +8 -4
- package/src/github/repo-apply-order-fake-providers.ts +119 -0
- package/src/github/repo-apply-order-fake-stack.ts +74 -0
- package/src/github/repo-auto-merge-gate.ts +57 -0
- package/src/github/repo-baseline-data.test.ts +13 -0
- package/src/github/repo-baseline-data.ts +11 -2
- package/src/github/repo-policy-apply-order.test.ts +72 -0
- package/src/github/repo-policy-stack.test.ts +59 -0
- package/src/github/repo-policy.ts +31 -3
- package/src/github/repository-ruleset-errors.ts +6 -4
- package/src/github/repository-ruleset-exact-adopt-k1.test.ts +109 -0
- package/src/github/repository-ruleset-exact-adopt.test.ts +14 -9
- package/src/github/repository-ruleset-form.ts +7 -3
- package/src/github/repository-ruleset-guards.ts +3 -2
- package/src/github/repository-ruleset-octokit.test.ts +141 -0
- package/src/github/repository-ruleset-octokit.ts +121 -30
- package/src/github/repository-ruleset-probe.ts +2 -1
- package/src/github/repository-ruleset-props.ts +7 -4
- package/src/github/repository-ruleset-providers.ts +46 -0
- package/src/github/repository-ruleset-wire.test.ts +38 -27
- package/src/github/repository-ruleset.ts +27 -3
- package/src/litellm/fake-litellm.ts +83 -78
- package/src/litellm/fake-stack.ts +13 -5
- package/src/litellm/index.ts +7 -21
- package/src/litellm/operations.ts +149 -0
- package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +17 -20
- package/src/litellm/pass-through-endpoint-replace.test.ts +3 -4
- package/src/litellm/pass-through-endpoint.test.ts +8 -8
- package/src/litellm/pass-through-endpoint.ts +21 -31
- package/src/litellm/pass-through-errors.ts +37 -0
- package/src/litellm/pass-through-form.ts +20 -10
- package/src/litellm/providers.ts +8 -7
- package/src/paperless/custom-field.test.ts +49 -40
- package/src/paperless/custom-field.ts +57 -33
- package/src/paperless/document-type.test.ts +48 -0
- package/src/paperless/document-type.ts +51 -22
- package/src/paperless/fake-paperless.ts +63 -72
- package/src/paperless/index.ts +7 -13
- package/src/paperless/matching-locate.test.ts +102 -0
- package/src/paperless/matching-locate.ts +80 -86
- package/src/paperless/matching-types.ts +49 -16
- package/src/paperless/matching.ts +77 -90
- package/src/paperless/providers.ts +14 -10
- package/src/paperless/storage-path.test.ts +56 -0
- package/src/paperless/storage-path.ts +53 -24
- package/src/paperless/tag-identity.test.ts +127 -0
- package/src/paperless/tag.test.ts +100 -0
- package/src/paperless/tag.ts +62 -32
- package/src/proxmox/acl.test.ts +145 -0
- package/src/proxmox/acl.ts +128 -150
- package/src/proxmox/distilled-pve.test.ts +231 -0
- package/src/proxmox/distilled-pve.ts +147 -0
- package/src/proxmox/members-timeout.test.ts +161 -0
- package/src/proxmox/members.test.ts +9 -0
- package/src/proxmox/members.ts +36 -2
- package/dist/litellm/client.d.ts +0 -76
- package/dist/litellm/client.d.ts.map +0 -1
- package/dist/litellm/credentials.d.ts +0 -48
- package/dist/litellm/credentials.d.ts.map +0 -1
- package/dist/litellm/generated/pass-through.d.ts +0 -71
- package/dist/litellm/generated/pass-through.d.ts.map +0 -1
- package/dist/paperless/client.d.ts +0 -37
- package/dist/paperless/client.d.ts.map +0 -1
- package/dist/paperless/credentials.d.ts +0 -40
- package/dist/paperless/credentials.d.ts.map +0 -1
- package/dist/paperless/errors.d.ts +0 -49
- package/dist/paperless/errors.d.ts.map +0 -1
- package/src/litellm/client.ts +0 -195
- package/src/litellm/credentials.ts +0 -73
- package/src/litellm/generated/pass-through.ts +0 -73
- package/src/paperless/client.ts +0 -88
- package/src/paperless/credentials.ts +0 -69
- package/src/paperless/errors.ts +0 -62
- package/src/paperless/matching-identity.test.ts +0 -194
- package/src/paperless/matching.test.ts +0 -128
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `DocumentType`'s exported production `spec` against a fake Paperless-ngx — proves this file's
|
|
3
|
+
* own `@distilled.cloud/paperless-ngx/document_types` wiring (right operations, right field
|
|
4
|
+
* names) round-trips for real. The shared `matching.ts` engine and the PR 163 identity switch are
|
|
5
|
+
* proven generically by `tag.test.ts`/`tag-identity.test.ts`/`matching-locate.test.ts`; this file
|
|
6
|
+
* does not repeat that coverage.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { spec } from './document-type.ts';
|
|
10
|
+
import { fakePaperless, run } from './fake-paperless.ts';
|
|
11
|
+
import { matchingOperations } from './matching.ts';
|
|
12
|
+
|
|
13
|
+
const ops = matchingOperations(spec);
|
|
14
|
+
|
|
15
|
+
test('create sends owner: null when undeclared, and the constraint-checked body', async () => {
|
|
16
|
+
const fake = fakePaperless();
|
|
17
|
+
await run(ops.reconcile({ name: 'Invoices' }, undefined), fake.fetch);
|
|
18
|
+
const post = fake.seen.find((s) => s.method === 'POST');
|
|
19
|
+
expect(post).toBeDefined();
|
|
20
|
+
expect(JSON.parse(post?.body ?? '{}')).toMatchObject({ name: 'Invoices', owner: null });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('a full create → read → patch → delete round trip', () => {
|
|
24
|
+
test('the real distilled wire path carries every field through', async () => {
|
|
25
|
+
const fake = fakePaperless();
|
|
26
|
+
const created = await run(
|
|
27
|
+
ops.reconcile({ isInsensitive: true, match: 'invoice', name: 'Invoices' }, undefined),
|
|
28
|
+
fake.fetch,
|
|
29
|
+
);
|
|
30
|
+
expect(created).toMatchObject({ isInsensitive: true, match: 'invoice', name: 'Invoices' });
|
|
31
|
+
|
|
32
|
+
const after = await run(
|
|
33
|
+
ops.reconcile({ isInsensitive: true, match: 'receipt', name: 'Invoices' }, created),
|
|
34
|
+
fake.fetch,
|
|
35
|
+
);
|
|
36
|
+
const patch = fake.seen.find((s) => s.method === 'PATCH');
|
|
37
|
+
expect(JSON.parse(patch?.body ?? '{}')).toEqual({ match: 'receipt' });
|
|
38
|
+
expect(after?.match).toBe('receipt');
|
|
39
|
+
|
|
40
|
+
await run(ops.destroy({ name: 'Invoices' }, after), fake.fetch);
|
|
41
|
+
expect(fake.rows('document_types').length).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a 404 on read folds to absent', async () => {
|
|
46
|
+
const fake = fakePaperless();
|
|
47
|
+
expect(await run(ops.read({ name: 'Nothing here' }), fake.fetch)).toBeUndefined();
|
|
48
|
+
});
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `Paperless.DocumentType` — a Paperless-ngx document type, create-or-update and never deleted.
|
|
3
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
|
|
4
|
+
* `matchingAlgorithm`'s type comes off the generated request type rather than being retyped, and
|
|
5
|
+
* for the 2026-09-24 transport migration this file follows exactly.
|
|
5
6
|
*/
|
|
6
7
|
import { Resource } from 'alchemy';
|
|
7
8
|
import * as Provider from 'alchemy/Provider';
|
|
9
|
+
import * as documentTypes from '@distilled.cloud/paperless-ngx/document_types';
|
|
8
10
|
import * as Effect from 'effect/Effect';
|
|
9
11
|
import type { PaperlessDocumentTypeRequest } from './generated/types/document-types.ts';
|
|
10
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
LOCATE_PAGE,
|
|
14
|
+
type MatchingRequirements,
|
|
15
|
+
type MatchingSpec,
|
|
16
|
+
matchingHandlers,
|
|
17
|
+
} from './matching.ts';
|
|
11
18
|
|
|
12
|
-
export interface DocumentTypeProps
|
|
19
|
+
export interface DocumentTypeProps {
|
|
13
20
|
/** ⚠️ maxLength 128 (generated constraint table). The locate key. */
|
|
14
21
|
name: string;
|
|
22
|
+
owner?: number;
|
|
15
23
|
/** ⚠️ maxLength 256. */
|
|
16
24
|
match?: string;
|
|
17
25
|
matchingAlgorithm?: PaperlessDocumentTypeRequest['matching_algorithm'];
|
|
@@ -39,36 +47,57 @@ export const DocumentType = Resource<DocumentType>('Paperless.DocumentType', {
|
|
|
39
47
|
defaultRemovalPolicy: 'retain',
|
|
40
48
|
});
|
|
41
49
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
/** ★ Exported for direct testing — see tag.ts's own note on this seam. */
|
|
51
|
+
export const spec: MatchingSpec<
|
|
52
|
+
DocumentTypeProps,
|
|
53
|
+
documentTypes.DocumentType,
|
|
54
|
+
DocumentTypeAttributes,
|
|
55
|
+
| documentTypes.CreateDocumentTypeError
|
|
56
|
+
| documentTypes.GetDocumentTypeError
|
|
57
|
+
| documentTypes.ListDocumentTypesError
|
|
58
|
+
| documentTypes.UpdateDocumentTypesPartialError
|
|
59
|
+
| documentTypes.DocumentTypesDestroyError
|
|
60
|
+
> = {
|
|
61
|
+
attributes: (live, props) => ({
|
|
62
|
+
id: live.id,
|
|
63
|
+
isInsensitive: live.is_insensitive === true,
|
|
64
|
+
match: live.match ?? '',
|
|
65
|
+
matchingAlgorithm: live.matching_algorithm ?? 0,
|
|
66
|
+
name: live.name,
|
|
67
|
+
owner: live.owner ?? undefined,
|
|
68
|
+
}),
|
|
69
|
+
create: (body) =>
|
|
70
|
+
documentTypes.createDocumentType(body as unknown as documentTypes.CreateDocumentTypeRequest),
|
|
57
71
|
describe: (props) => `document_types ${props.name}`,
|
|
72
|
+
destroy: (id) => documentTypes.documentTypesDestroy({ id }),
|
|
58
73
|
endpoint: {
|
|
59
74
|
create: 'paperless:POST /api/document_types/',
|
|
60
75
|
update: 'paperless:PATCH /api/document_types/{id}/',
|
|
61
76
|
},
|
|
62
77
|
fields: [
|
|
63
|
-
{ key: 'match', live: (r) => r
|
|
78
|
+
{ key: 'match', live: (r) => r.match, prop: (p) => p.match },
|
|
64
79
|
{
|
|
65
80
|
key: 'matching_algorithm',
|
|
66
|
-
live: (r) => r
|
|
81
|
+
live: (r) => r.matching_algorithm,
|
|
67
82
|
prop: (p) => p.matchingAlgorithm,
|
|
68
83
|
},
|
|
69
|
-
{ key: 'is_insensitive', live: (r) => r
|
|
84
|
+
{ key: 'is_insensitive', live: (r) => r.is_insensitive, prop: (p) => p.isInsensitive },
|
|
70
85
|
],
|
|
71
|
-
|
|
86
|
+
getById: (id) =>
|
|
87
|
+
documentTypes
|
|
88
|
+
.getDocumentType({ id })
|
|
89
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
90
|
+
list: (props) =>
|
|
91
|
+
documentTypes.listDocumentTypes({ name__iexact: props.name, page_size: LOCATE_PAGE }),
|
|
92
|
+
ownerOf: (live) => live.owner ?? undefined,
|
|
93
|
+
update: (id, body) =>
|
|
94
|
+
documentTypes.updateDocumentTypesPartial({
|
|
95
|
+
...(body as unknown as Omit<documentTypes.UpdateDocumentTypesPartialRequest, 'id'>),
|
|
96
|
+
id,
|
|
97
|
+
}),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const handlers = matchingHandlers(spec);
|
|
72
101
|
|
|
73
102
|
export const DocumentTypeProvider = () =>
|
|
74
103
|
Provider.effect(DocumentType, Effect.succeed(DocumentType.Provider.of(handlers)));
|
|
@@ -1,59 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A
|
|
3
|
-
*
|
|
2
|
+
* A minimal in-memory Paperless-ngx REST API for this family's tests — an in-memory `fetch`
|
|
3
|
+
* handed to Effect's real `FetchHttpClient` through its `Fetch` reference, mirroring
|
|
4
|
+
* `../netbox/fake-netbox.ts` and `../forgejo/fake-forgejo.ts`. Tests exercise distilled's REAL
|
|
5
|
+
* path assembly, JSON encode/decode and status→typed-error matching (`404` → `NotFound`, `403` →
|
|
6
|
+
* `Forbidden`, `400` → `BadRequest`, …) — nothing about that matching is re-implemented here, only
|
|
7
|
+
* the wire responses and in-memory rows a scenario needs.
|
|
4
8
|
*
|
|
5
|
-
* ⛔ TEST-ONLY. No provider imports this file.
|
|
6
|
-
* taxonomy collections and answers `name__iexact` the way Django does — case-insensitive,
|
|
7
|
-
*
|
|
9
|
+
* ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. Stores rows in memory for the four
|
|
10
|
+
* taxonomy collections and answers `name__iexact` the way Django does — case-insensitive, never
|
|
11
|
+
* collapsing two differently-cased rows into one. Ported from the pre-migration `Bun.serve`
|
|
12
|
+
* fake: production now goes through `@distilled.cloud/paperless-ngx`'s `CredentialsFromEnv`,
|
|
13
|
+
* which resolves through Effect `Config` and snapshots `process.env` once (measured in the
|
|
14
|
+
* forgejo migration, kit PR 180) — a test can no longer point production code at a fake server
|
|
15
|
+
* by mutating env vars, so this hands an explicit `credentials()` layer instead.
|
|
8
16
|
*/
|
|
17
|
+
import { type Credentials, credentials } from '@distilled.cloud/paperless-ngx/Credentials';
|
|
18
|
+
import type { PaperlessNgxOpContext } from '@distilled.cloud/paperless-ngx/Protocol';
|
|
9
19
|
import * as Effect from 'effect/Effect';
|
|
20
|
+
import * as Layer from 'effect/Layer';
|
|
10
21
|
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
11
|
-
import type { PaperlessRequirements } from './client.ts';
|
|
12
|
-
import { type PaperlessCredentialsError, environmentLayer } from './credentials.ts';
|
|
13
|
-
import type { PaperlessError } from './errors.ts';
|
|
14
22
|
|
|
15
|
-
export const
|
|
23
|
+
export const FAKE_BASE = 'https://paperless.example.com';
|
|
24
|
+
export const FAKE_TOKEN = 'placeholder-paperless-token';
|
|
16
25
|
|
|
17
26
|
export interface Seen {
|
|
18
27
|
readonly method: string;
|
|
19
28
|
readonly path: string;
|
|
20
|
-
readonly headers: Headers;
|
|
21
29
|
readonly body: string;
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
export type Row = Record<string, unknown> & { id: number };
|
|
25
33
|
|
|
26
34
|
const COLLECTIONS = ['tags', 'document_types', 'storage_paths', 'custom_fields'] as const;
|
|
35
|
+
type Collection = (typeof COLLECTIONS)[number];
|
|
27
36
|
|
|
28
37
|
export interface FakePaperless {
|
|
29
|
-
readonly
|
|
38
|
+
readonly fetch: typeof globalThis.fetch;
|
|
30
39
|
readonly seen: Seen[];
|
|
31
|
-
readonly rows: (collection:
|
|
32
|
-
/** `undefined` to answer normally; a status to force it once
|
|
40
|
+
readonly rows: (collection: Collection) => readonly Row[];
|
|
41
|
+
/** `undefined` to answer normally; a status to force it once for the NEXT call only. */
|
|
33
42
|
forceStatus: number | undefined;
|
|
34
|
-
stop(): void;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
|
-
const nextId = (store: Row[]): number =>
|
|
45
|
+
const nextId = (store: readonly Row[]): number =>
|
|
38
46
|
store.length === 0 ? 1 : Math.max(...store.map((r) => r.id)) + 1;
|
|
39
47
|
|
|
48
|
+
const json = (status: number, body: unknown): Response =>
|
|
49
|
+
new Response(status === 204 ? null : JSON.stringify(body), {
|
|
50
|
+
headers: { 'content-type': 'application/json' },
|
|
51
|
+
status,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A stateful CRUD router across the four collections — this is what makes it possible to test
|
|
56
|
+
* `matching.ts`'s full create → read-back → PATCH → DELETE lifecycle, not just one canned
|
|
57
|
+
* response per test the way `fakeNetbox`/`fakeForgejo`'s simpler `route` callback does.
|
|
58
|
+
*/
|
|
40
59
|
export const fakePaperless = (): FakePaperless => {
|
|
41
60
|
const seen: Seen[] = [];
|
|
42
|
-
const store = new Map<
|
|
61
|
+
const store = new Map<Collection, Row[]>(COLLECTIONS.map((c) => [c, []]));
|
|
43
62
|
let forced: number | undefined;
|
|
44
63
|
|
|
45
|
-
const fetch = async (
|
|
64
|
+
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
65
|
+
const request =
|
|
66
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
46
67
|
const url = new URL(request.url);
|
|
47
68
|
const body = await request.text();
|
|
48
|
-
seen.push({
|
|
49
|
-
|
|
50
|
-
headers: request.headers,
|
|
51
|
-
method: request.method,
|
|
52
|
-
path: `${url.pathname}${url.search}`,
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
const token = request.headers.get('Authorization');
|
|
56
|
-
if (token !== `Token ${FAKE_TOKEN}`) return json(401, { detail: 'Invalid token.' });
|
|
69
|
+
seen.push({ body, method: request.method, path: `${url.pathname}${url.search}` });
|
|
70
|
+
|
|
57
71
|
if (forced !== undefined) {
|
|
58
72
|
const status = forced;
|
|
59
73
|
forced = undefined;
|
|
@@ -62,7 +76,7 @@ export const fakePaperless = (): FakePaperless => {
|
|
|
62
76
|
|
|
63
77
|
const segments = url.pathname.split('/').filter((s) => s !== '');
|
|
64
78
|
// ['api', collection, (id)?]
|
|
65
|
-
const collection = segments[1] as
|
|
79
|
+
const collection = segments[1] as Collection | undefined;
|
|
66
80
|
if (collection === undefined || !COLLECTIONS.includes(collection))
|
|
67
81
|
return json(404, { detail: 'Not found.' });
|
|
68
82
|
const rows = store.get(collection) as Row[];
|
|
@@ -96,10 +110,10 @@ export const fakePaperless = (): FakePaperless => {
|
|
|
96
110
|
return new Response(null, { status: 204 });
|
|
97
111
|
}
|
|
98
112
|
return json(405, { detail: 'method not allowed' });
|
|
99
|
-
};
|
|
113
|
+
}) as typeof globalThis.fetch;
|
|
100
114
|
|
|
101
|
-
const server = Bun.serve({ fetch, hostname: '127.0.0.1', port: 0 });
|
|
102
115
|
return {
|
|
116
|
+
fetch,
|
|
103
117
|
get forceStatus() {
|
|
104
118
|
return forced;
|
|
105
119
|
},
|
|
@@ -108,49 +122,26 @@ export const fakePaperless = (): FakePaperless => {
|
|
|
108
122
|
},
|
|
109
123
|
rows: (collection) => store.get(collection) ?? [],
|
|
110
124
|
seen,
|
|
111
|
-
stop: () => void server.stop(true),
|
|
112
|
-
url: server.url.origin,
|
|
113
125
|
};
|
|
114
126
|
};
|
|
115
127
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
else process.env['PAPERLESS_TOKEN'] = savedToken;
|
|
137
|
-
}
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
/** Run an effect through the real `FetchHttpClient` and the real environment credentials layer. */
|
|
141
|
-
export const run = <A>(
|
|
142
|
-
effect: Effect.Effect<A, PaperlessError | PaperlessCredentialsError, PaperlessRequirements>,
|
|
143
|
-
) =>
|
|
144
|
-
Effect.runPromise(
|
|
145
|
-
effect.pipe(Effect.provide(FetchHttpClient.layer), Effect.provide(environmentLayer)),
|
|
146
|
-
);
|
|
147
|
-
|
|
148
|
-
export const runFailure = <A>(
|
|
149
|
-
effect: Effect.Effect<A, PaperlessError | PaperlessCredentialsError, PaperlessRequirements>,
|
|
150
|
-
) =>
|
|
151
|
-
Effect.runPromise(
|
|
152
|
-
Effect.flip(effect).pipe(
|
|
153
|
-
Effect.provide(FetchHttpClient.layer),
|
|
154
|
-
Effect.provide(environmentLayer),
|
|
155
|
-
),
|
|
156
|
-
);
|
|
128
|
+
/**
|
|
129
|
+
* distilled Credentials + the real FetchHttpClient over the fake — `PaperlessNgxOpContext`, what
|
|
130
|
+
* `matchingOperations(spec)` (matching.ts, unwrapped — not `matchingHandlers`, which bakes in
|
|
131
|
+
* `CredentialsFromEnv` and so cannot be pointed at a fake server, per this file's own header)
|
|
132
|
+
* needs to run against a fake.
|
|
133
|
+
*/
|
|
134
|
+
export const fakePaperlessLayer = (
|
|
135
|
+
fetchFn: typeof globalThis.fetch,
|
|
136
|
+
creds: Layer.Layer<Credentials> = credentials({ baseUrl: FAKE_BASE, token: FAKE_TOKEN }),
|
|
137
|
+
) => Layer.mergeAll(creds, FetchHttpClient.layer, Layer.succeed(FetchHttpClient.Fetch, fetchFn));
|
|
138
|
+
|
|
139
|
+
export const run = <A, E>(
|
|
140
|
+
effect: Effect.Effect<A, E, PaperlessNgxOpContext>,
|
|
141
|
+
fetchFn: typeof globalThis.fetch,
|
|
142
|
+
) => Effect.runPromise(effect.pipe(Effect.provide(fakePaperlessLayer(fetchFn))));
|
|
143
|
+
|
|
144
|
+
export const runFailure = <A, E>(
|
|
145
|
+
effect: Effect.Effect<A, E, PaperlessNgxOpContext>,
|
|
146
|
+
fetchFn: typeof globalThis.fetch,
|
|
147
|
+
) => Effect.runPromise(Effect.flip(effect).pipe(Effect.provide(fakePaperlessLayer(fetchFn))));
|
package/src/paperless/index.ts
CHANGED
|
@@ -5,20 +5,14 @@
|
|
|
5
5
|
* header. `matching.ts`'s internals and the raw generated modules are not re-exported; a
|
|
6
6
|
* consumer wanting the same guard a Resource uses gets it through `constraintsFor`/
|
|
7
7
|
* `bodyViolations`, not by importing `generated/` directly.
|
|
8
|
+
*
|
|
9
|
+
* 🔴 `PaperlessCredentials`/`PaperlessError` AND FRIENDS ARE GONE (2026-09-24, the
|
|
10
|
+
* `@distilled.cloud/paperless-ngx` migration — matching.ts's header), the same way
|
|
11
|
+
* `../netbox/index.ts` dropped `NetboxError`/`apiBase` in its own migration. A caller that
|
|
12
|
+
* needs the typed errors now imports them from `@distilled.cloud/paperless-ngx` directly (e.g.
|
|
13
|
+
* `@distilled.cloud/paperless-ngx/tags`'s `NotFound`); nothing in this estate imported the old
|
|
14
|
+
* names (measured 2026-09-24).
|
|
8
15
|
*/
|
|
9
|
-
export {
|
|
10
|
-
PaperlessCredentials,
|
|
11
|
-
type PaperlessCredentialValue,
|
|
12
|
-
PaperlessCredentialsError,
|
|
13
|
-
environmentLayer as paperlessEnvironmentLayer,
|
|
14
|
-
} from './credentials.ts';
|
|
15
|
-
export {
|
|
16
|
-
PaperlessNotFound,
|
|
17
|
-
PaperlessUnauthorized,
|
|
18
|
-
PaperlessValidation,
|
|
19
|
-
PaperlessUnavailable,
|
|
20
|
-
type PaperlessError,
|
|
21
|
-
} from './errors.ts';
|
|
22
16
|
export {
|
|
23
17
|
PAPERLESS_CONSTRAINTS,
|
|
24
18
|
bodyViolations,
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `soleMatch`, `locateOne` and the PR 163 identity switch (`fetchLive`) — identity resolution, in
|
|
3
|
+
* isolation. Pure/Effect only (no HTTP, `R = never`): every fixture is `Effect.succeed`, mirroring
|
|
4
|
+
* `../netbox/resource.test.ts`'s own `locateOne` tests. `tag.test.ts`/`tag-identity.test.ts`
|
|
5
|
+
* exercise the real distilled wire path this feeds from, plus the full PR 163 regression suite
|
|
6
|
+
* this file does not repeat.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ THE FAILURE `soleMatch` GUARDS IS SILENT AND SELF-RENEWING. If an ambiguous match took the
|
|
9
|
+
* first row, adopt would bind to it, the next plan would bind to the other, and every plan
|
|
10
|
+
* after that would report drift that is not there. Failing loudly is the only outcome an
|
|
11
|
+
* operator can act on. Ported verbatim from the pre-migration `client.test.ts`.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import { LOCATE_PAGE, fetchLive, locateOne, soleMatch } from './matching-locate.ts';
|
|
16
|
+
|
|
17
|
+
describe('soleMatch', () => {
|
|
18
|
+
test('no candidate is absent, not an error — that is what plans a create', () => {
|
|
19
|
+
expect(soleMatch([], 'tags Invoices')).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('exactly one candidate is the object', () => {
|
|
23
|
+
expect(soleMatch([{ id: 7 }], 'tags Invoices')).toEqual({ id: 7 });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** ⛔ Two rows means the identity rule is wrong, and the message says which object it was. */
|
|
27
|
+
test('more than one candidate throws and names the object', () => {
|
|
28
|
+
expect(() => soleMatch([{ id: 7 }, { id: 8 }], 'tags Invoices')).toThrow(
|
|
29
|
+
/tags Invoices matched 2 Paperless objects/,
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
interface Row {
|
|
35
|
+
readonly id: number;
|
|
36
|
+
readonly name: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const page = (results: readonly Row[]) => Effect.succeed({ count: results.length, results });
|
|
40
|
+
|
|
41
|
+
describe('locateOne', () => {
|
|
42
|
+
test('an empty page is absent', async () => {
|
|
43
|
+
expect(await Effect.runPromise(locateOne(page([]), 'tags Invoices'))).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('one row with no `identifies` narrowing is the object', async () => {
|
|
47
|
+
const row = await Effect.runPromise(
|
|
48
|
+
locateOne(page([{ id: 7, name: 'Invoices' }]), 'tags Invoices'),
|
|
49
|
+
);
|
|
50
|
+
expect(row).toEqual({ id: 7, name: 'Invoices' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('`identifies` narrows several server-side matches to the declared one', async () => {
|
|
54
|
+
const rows = [
|
|
55
|
+
{ id: 7, name: 'Invoices' },
|
|
56
|
+
{ id: 8, name: 'invoices' },
|
|
57
|
+
];
|
|
58
|
+
const row = await Effect.runPromise(
|
|
59
|
+
locateOne(page(rows), 'tags Invoices', (r) => r.name === 'invoices'),
|
|
60
|
+
);
|
|
61
|
+
expect(row).toEqual({ id: 8, name: 'invoices' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('more rows than the page width is a defect, not a truncation', async () => {
|
|
65
|
+
const rows = Array.from({ length: LOCATE_PAGE + 1 }, (_, i) => ({ id: i, name: 'x' }));
|
|
66
|
+
await expect(Effect.runPromise(locateOne(page(rows), 'tags Invoices'))).rejects.toThrow(
|
|
67
|
+
/matched 21 rows, more than the 20-row page/,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('fetchLive (the PR 163 identity switch)', () => {
|
|
73
|
+
const byName = (rows: readonly Row[]) => (props: { name: string }) =>
|
|
74
|
+
locateOne(page(rows), `tags ${props.name}`, (r) => r.name === props.name);
|
|
75
|
+
const byId = (rows: readonly Row[]) => (id: number) =>
|
|
76
|
+
Effect.succeed(rows.find((r) => r.id === id));
|
|
77
|
+
const describe_ = (props: { name: string }) => `tags ${props.name}`;
|
|
78
|
+
|
|
79
|
+
test('no output: locates by name', async () => {
|
|
80
|
+
const rows = [{ id: 7, name: 'Invoices' }];
|
|
81
|
+
const live = await Effect.runPromise(
|
|
82
|
+
fetchLive({ name: 'Invoices' }, undefined, byName(rows), byId(rows), describe_),
|
|
83
|
+
);
|
|
84
|
+
expect(live?.id).toBe(7);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('output present: locates by id, ignoring a since-changed name', async () => {
|
|
88
|
+
const rows = [{ id: 7, name: 'Renamed' }];
|
|
89
|
+
const live = await Effect.runPromise(
|
|
90
|
+
fetchLive({ name: 'Invoices' }, { id: 7 }, byName(rows), byId(rows), describe_),
|
|
91
|
+
);
|
|
92
|
+
expect(live?.id).toBe(7);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('output.id no longer live: refuses rather than falling through to a create', async () => {
|
|
96
|
+
await expect(
|
|
97
|
+
Effect.runPromise(
|
|
98
|
+
fetchLive({ name: 'Invoices' }, { id: 7 }, byName([]), byId([]), describe_),
|
|
99
|
+
),
|
|
100
|
+
).rejects.toThrow(/no longer exists live|deleted out of band/);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -4,101 +4,95 @@
|
|
|
4
4
|
* live Paperless row IS this declaration — by name while there is no state, by the stored
|
|
5
5
|
* `output.id` once there is one. See `matching.ts`'s header for the PR 163 red-team HIGH finding
|
|
6
6
|
* this identity switch fixes.
|
|
7
|
+
*
|
|
8
|
+
* ★ EVERY EXPORT HERE IS GENERIC OVER `R`, NOT TIED TO `PaperlessNgxOpContext` — mirroring
|
|
9
|
+
* `../netbox/resource.ts`'s standalone `locateOne`. `matching.ts` wires these against a real
|
|
10
|
+
* spec's `PaperlessNgxOpContext`-typed operations; `matching-locate.test.ts` wires them against
|
|
11
|
+
* plain `Effect.succeed` fixtures (`R = never`) — the same function, no fake HTTP needed to
|
|
12
|
+
* prove the pure rule.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `spec.getById`, NOT HERE —
|
|
15
|
+
* the direct successor to the old, centralized `absentOn404`, and the same split
|
|
16
|
+
* `../netbox/resource.ts` and `../forgejo/resource.ts` use. `Effect.catchTag`'s tag-literal
|
|
17
|
+
* inference needs a concrete error union to resolve `"NotFound"` against; a spec's `E` is only
|
|
18
|
+
* concrete at each resource file's own call site (`GetTagError`, `GetDocumentTypeError`, …). A
|
|
19
|
+
* LIST call is never folded here either — an empty `results` page is a normal 200, not a 404
|
|
20
|
+
* (measured live, kit PR 194).
|
|
7
21
|
*/
|
|
8
22
|
import * as Effect from 'effect/Effect';
|
|
9
|
-
import { PaperlessNotFound } from './errors.ts';
|
|
10
|
-
import { type PaperlessList, type PaperlessRow, paperless } from './client.ts';
|
|
11
|
-
import type { MatchingError, MatchingProps, MatchingRequirements } from './matching-types.ts';
|
|
12
23
|
|
|
13
24
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
25
|
+
* ★ Wide enough to see every case-variant candidate `name__iexact` returns; a natural key past it
|
|
26
|
+
* is not one. Unchanged from the pre-migration `client.ts`'s own value.
|
|
16
27
|
*/
|
|
17
|
-
export const
|
|
18
|
-
io.pipe(
|
|
19
|
-
Effect.catchIf(
|
|
20
|
-
(cause): cause is PaperlessNotFound => cause instanceof PaperlessNotFound,
|
|
21
|
-
() => Effect.succeed(undefined as A),
|
|
22
|
-
),
|
|
23
|
-
);
|
|
24
|
-
|
|
25
|
-
/** ★ Wide enough to see every case-variant candidate `name__iexact` returns; a natural key past it is not one. */
|
|
26
|
-
const LOCATE_PAGE = 20;
|
|
27
|
-
|
|
28
|
-
/** ⚠️ Exported — `matching.ts`'s `patchBody`/`matches` compare the live `owner` too (PR 163). */
|
|
29
|
-
export const ownerOf = (row: PaperlessRow): number | undefined =>
|
|
30
|
-
typeof row['owner'] === 'number' ? row['owner'] : undefined;
|
|
28
|
+
export const LOCATE_PAGE = 20;
|
|
31
29
|
|
|
32
30
|
/**
|
|
33
|
-
* The
|
|
34
|
-
*
|
|
31
|
+
* The single row a filtered list returns, or `undefined`.
|
|
32
|
+
*
|
|
33
|
+
* ⛔ MORE THAN ONE MATCH IS A DEFECT, NOT A REASON TO TAKE THE FIRST. If an ambiguous match took
|
|
34
|
+
* the first row, adopt would bind to it, the next plan would bind to the other, and every plan
|
|
35
|
+
* after that would report drift that is not there — while "fixing" it PATCHed one object with
|
|
36
|
+
* the other's declaration. Failing loudly is the only outcome an operator can act on.
|
|
35
37
|
*/
|
|
36
|
-
export const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
})
|
|
40
|
-
/**
|
|
41
|
-
* ★ THE NO-STATE LOCATE. Used only when there is no `output` yet: a genuine first create, or
|
|
42
|
-
* the engine's own adoption probe (`provider.read`, always called with `output: undefined`).
|
|
43
|
-
*/
|
|
44
|
-
const fetchByName = (props: Props) =>
|
|
45
|
-
absentOn404(
|
|
46
|
-
paperless<PaperlessList<PaperlessRow>>(
|
|
47
|
-
'GET',
|
|
48
|
-
`${spec.collection}/?name__iexact=${encodeURIComponent(props.name)}&page_size=${String(LOCATE_PAGE)}`,
|
|
49
|
-
),
|
|
50
|
-
).pipe(
|
|
51
|
-
Effect.map((list) => {
|
|
52
|
-
if (list !== undefined && list.count > LOCATE_PAGE) {
|
|
53
|
-
throw new Error(
|
|
54
|
-
`${spec.describe(props)}: name__iexact matched ${String(list.count)} rows, more than ` +
|
|
55
|
-
`the ${String(LOCATE_PAGE)}-row page this reads. Narrow the name rather than paging.`,
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
const rows = (list?.results ?? []).filter(
|
|
59
|
-
(row) => row['name'] === props.name && ownerOf(row) === (props.owner ?? undefined),
|
|
60
|
-
);
|
|
61
|
-
if (rows.length > 1) {
|
|
62
|
-
throw new Error(
|
|
63
|
-
`${spec.describe(props)}: matched ${String(rows.length)} Paperless objects with the same (name, owner).`,
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
return rows[0];
|
|
67
|
-
}),
|
|
38
|
+
export const soleMatch = <T>(rows: readonly T[], describe: string): T | undefined => {
|
|
39
|
+
if (rows.length > 1) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`${describe} matched ${String(rows.length)} Paperless objects with the same (name, owner).`,
|
|
68
42
|
);
|
|
43
|
+
}
|
|
44
|
+
return rows[0];
|
|
45
|
+
};
|
|
69
46
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
: fetchById(output.id).pipe(
|
|
87
|
-
Effect.flatMap((live) =>
|
|
88
|
-
live === undefined
|
|
89
|
-
? Effect.die(
|
|
90
|
-
new Error(
|
|
91
|
-
`${spec.describe(props)}: id ${String(output.id)} is recorded in state but no ` +
|
|
92
|
-
'longer exists live (deleted out of band). Refusing to create a replacement ' +
|
|
93
|
-
'— adopt the live object back under this declaration, or remove it from ' +
|
|
94
|
-
'state, before deploying again.',
|
|
95
|
-
),
|
|
96
|
-
)
|
|
97
|
-
: Effect.succeed(live),
|
|
98
|
-
),
|
|
47
|
+
/**
|
|
48
|
+
* List, then narrow to at most one candidate. Any Paperless resource whose locate is "filter
|
|
49
|
+
* server-side, then disambiguate in this process" reaches this once rather than reimplementing
|
|
50
|
+
* the page-width assertion and the ambiguity defect per resource.
|
|
51
|
+
*/
|
|
52
|
+
export const locateOne = <Live, E, R>(
|
|
53
|
+
list: Effect.Effect<{ readonly count: number; readonly results: readonly Live[] }, E, R>,
|
|
54
|
+
describe: string,
|
|
55
|
+
identifies?: (row: Live) => boolean,
|
|
56
|
+
): Effect.Effect<Live | undefined, E, R> =>
|
|
57
|
+
list.pipe(
|
|
58
|
+
Effect.map((page) => {
|
|
59
|
+
if (page.count > LOCATE_PAGE) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`${describe}: name__iexact matched ${String(page.count)} rows, more than the ` +
|
|
62
|
+
`${String(LOCATE_PAGE)}-row page this reads. Narrow the name rather than paging.`,
|
|
99
63
|
);
|
|
64
|
+
}
|
|
65
|
+
const rows = identifies === undefined ? page.results : page.results.filter(identifies);
|
|
66
|
+
return soleMatch(rows, describe);
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
100
69
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
70
|
+
/**
|
|
71
|
+
* ⛔ THE IDENTITY SWITCH (PR 163, red-team HIGH finding — see `matching.ts`'s header). `output`
|
|
72
|
+
* defined means this declaration has already written something: identity is `output.id`, full
|
|
73
|
+
* stop, and a changed `name`/`owner` in the caller's props is drift to PATCH, never a reason to
|
|
74
|
+
* relocate. `output` undefined means there is nothing to be wrong about yet — locate by name.
|
|
75
|
+
*/
|
|
76
|
+
export const fetchLive = <Props, Live, Attributes extends { readonly id: number }, E, R>(
|
|
77
|
+
props: Props,
|
|
78
|
+
output: Attributes | undefined,
|
|
79
|
+
fetchByName: (props: Props) => Effect.Effect<Live | undefined, E, R>,
|
|
80
|
+
fetchById: (id: number) => Effect.Effect<Live | undefined, E, R>,
|
|
81
|
+
describe: (props: Props) => string,
|
|
82
|
+
): Effect.Effect<Live | undefined, E, R> =>
|
|
83
|
+
output === undefined
|
|
84
|
+
? fetchByName(props)
|
|
85
|
+
: fetchById(output.id).pipe(
|
|
86
|
+
Effect.flatMap((live) =>
|
|
87
|
+
live === undefined
|
|
88
|
+
? Effect.die(
|
|
89
|
+
new Error(
|
|
90
|
+
`${describe(props)}: id ${String(output.id)} is recorded in state but no ` +
|
|
91
|
+
'longer exists live (deleted out of band). Refusing to create a replacement ' +
|
|
92
|
+
'— adopt the live object back under this declaration, or remove it from ' +
|
|
93
|
+
'state, before deploying again.',
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
: Effect.succeed(live),
|
|
97
|
+
),
|
|
98
|
+
);
|