@sanoma-ai/workflows 0.0.0-stage → 0.1.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 (148) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +493 -2
  3. package/dist/approvals.d.ts +46 -0
  4. package/dist/approvals.js +111 -0
  5. package/dist/approvals.js.map +1 -0
  6. package/dist/ast.d.ts +57 -0
  7. package/dist/ast.js +81 -0
  8. package/dist/ast.js.map +1 -0
  9. package/dist/call.d.ts +33 -0
  10. package/dist/call.js +402 -0
  11. package/dist/call.js.map +1 -0
  12. package/dist/client.d.ts +167 -0
  13. package/dist/client.js +400 -0
  14. package/dist/client.js.map +1 -0
  15. package/dist/config.d.ts +126 -0
  16. package/dist/config.js +247 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/credentials.d.ts +30 -0
  19. package/dist/credentials.js +193 -0
  20. package/dist/credentials.js.map +1 -0
  21. package/dist/cucumber.d.ts +64 -0
  22. package/dist/cucumber.js +158 -0
  23. package/dist/cucumber.js.map +1 -0
  24. package/dist/datafile.d.ts +62 -0
  25. package/dist/datafile.js +326 -0
  26. package/dist/datafile.js.map +1 -0
  27. package/dist/db.d.ts +58 -0
  28. package/dist/db.js +179 -0
  29. package/dist/db.js.map +1 -0
  30. package/dist/define.d.ts +205 -0
  31. package/dist/define.js +103 -0
  32. package/dist/define.js.map +1 -0
  33. package/dist/describe.d.ts +135 -0
  34. package/dist/describe.js +127 -0
  35. package/dist/describe.js.map +1 -0
  36. package/dist/drift.d.ts +75 -0
  37. package/dist/drift.js +90 -0
  38. package/dist/drift.js.map +1 -0
  39. package/dist/env.d.ts +49 -0
  40. package/dist/env.js +83 -0
  41. package/dist/env.js.map +1 -0
  42. package/dist/errors.d.ts +88 -0
  43. package/dist/errors.js +148 -0
  44. package/dist/errors.js.map +1 -0
  45. package/dist/expectations.d.ts +26 -0
  46. package/dist/expectations.js +137 -0
  47. package/dist/expectations.js.map +1 -0
  48. package/dist/fake.d.ts +55 -0
  49. package/dist/fake.js +87 -0
  50. package/dist/fake.js.map +1 -0
  51. package/dist/fill.d.ts +9 -0
  52. package/dist/fill.js +17 -0
  53. package/dist/fill.js.map +1 -0
  54. package/dist/index.d.ts +19 -0
  55. package/dist/index.js +25 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/ledger.d.ts +217 -0
  58. package/dist/ledger.js +251 -0
  59. package/dist/ledger.js.map +1 -0
  60. package/dist/lint.d.ts +32 -0
  61. package/dist/lint.js +168 -0
  62. package/dist/lint.js.map +1 -0
  63. package/dist/log.d.ts +24 -0
  64. package/dist/log.js +83 -0
  65. package/dist/log.js.map +1 -0
  66. package/dist/op.d.ts +257 -0
  67. package/dist/op.js +188 -0
  68. package/dist/op.js.map +1 -0
  69. package/dist/outline.d.ts +179 -0
  70. package/dist/outline.js +506 -0
  71. package/dist/outline.js.map +1 -0
  72. package/dist/paths.d.ts +11 -0
  73. package/dist/paths.js +30 -0
  74. package/dist/paths.js.map +1 -0
  75. package/dist/plugin.d.ts +28 -0
  76. package/dist/plugin.js +25 -0
  77. package/dist/plugin.js.map +1 -0
  78. package/dist/policy.d.ts +114 -0
  79. package/dist/policy.js +60 -0
  80. package/dist/policy.js.map +1 -0
  81. package/dist/resource.d.ts +212 -0
  82. package/dist/resource.js +285 -0
  83. package/dist/resource.js.map +1 -0
  84. package/dist/resources.d.ts +62 -0
  85. package/dist/resources.js +197 -0
  86. package/dist/resources.js.map +1 -0
  87. package/dist/run.d.ts +87 -0
  88. package/dist/run.js +2 -0
  89. package/dist/run.js.map +1 -0
  90. package/dist/sandbox.d.ts +33 -0
  91. package/dist/sandbox.js +123 -0
  92. package/dist/sandbox.js.map +1 -0
  93. package/dist/scenario.d.ts +212 -0
  94. package/dist/scenario.js +1079 -0
  95. package/dist/scenario.js.map +1 -0
  96. package/dist/scenarios.d.ts +55 -0
  97. package/dist/scenarios.js +71 -0
  98. package/dist/scenarios.js.map +1 -0
  99. package/dist/shared.d.ts +61 -0
  100. package/dist/shared.js +77 -0
  101. package/dist/shared.js.map +1 -0
  102. package/dist/steps.d.ts +76 -0
  103. package/dist/steps.js +23 -0
  104. package/dist/steps.js.map +1 -0
  105. package/dist/version.d.ts +29 -0
  106. package/dist/version.js +76 -0
  107. package/dist/version.js.map +1 -0
  108. package/dist/worker.d.ts +34 -0
  109. package/dist/worker.js +219 -0
  110. package/dist/worker.js.map +1 -0
  111. package/oxlint.json +128 -0
  112. package/package.json +91 -3
  113. package/src/approvals.ts +157 -0
  114. package/src/ast.ts +115 -0
  115. package/src/call.ts +468 -0
  116. package/src/client.ts +534 -0
  117. package/src/config.ts +381 -0
  118. package/src/credentials.ts +211 -0
  119. package/src/cucumber.ts +230 -0
  120. package/src/datafile.ts +409 -0
  121. package/src/db.ts +197 -0
  122. package/src/define.ts +285 -0
  123. package/src/describe.ts +262 -0
  124. package/src/drift.ts +149 -0
  125. package/src/env.ts +122 -0
  126. package/src/errors.ts +202 -0
  127. package/src/expectations.ts +165 -0
  128. package/src/fake.ts +156 -0
  129. package/src/fill.ts +24 -0
  130. package/src/index.ts +120 -0
  131. package/src/ledger.ts +419 -0
  132. package/src/lint.ts +208 -0
  133. package/src/log.ts +82 -0
  134. package/src/op.ts +399 -0
  135. package/src/outline.ts +620 -0
  136. package/src/paths.ts +32 -0
  137. package/src/plugin.ts +37 -0
  138. package/src/policy.ts +139 -0
  139. package/src/resource.ts +447 -0
  140. package/src/resources.ts +256 -0
  141. package/src/run.ts +91 -0
  142. package/src/sandbox.ts +157 -0
  143. package/src/scenario.ts +1328 -0
  144. package/src/scenarios.ts +111 -0
  145. package/src/shared.ts +116 -0
  146. package/src/steps.ts +90 -0
  147. package/src/version.ts +86 -0
  148. package/src/worker.ts +253 -0
package/src/plugin.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { readDataFile } from "./datafile.ts";
2
+
3
+ // An oxlint JS plugin with one rule, `sanoma/data-file`: the data-file subset `readResources`
4
+ // reads, the same check as `lintResources`, on the tree oxlint has already parsed. The
5
+ // package's `oxlint.json` turns it on for `resources/**`. Typed here by the little it uses, so
6
+ // the package needs no oxlint types.
7
+
8
+ interface Context {
9
+ filename: string;
10
+ report(diagnostic: { message: string; node: { range: [number, number] } }): void;
11
+ }
12
+
13
+ const plugin = {
14
+ meta: { name: "sanoma" },
15
+ rules: {
16
+ "data-file": {
17
+ meta: {
18
+ type: "problem",
19
+ docs: {
20
+ description:
21
+ "A data file under resources/ holds only imports, `export const <name> = <vendor>.<type>({ … })` with literal fields, and `export default [ … ]`",
22
+ },
23
+ },
24
+ create(context: Context) {
25
+ return {
26
+ Program(program: unknown) {
27
+ for (const { start, end, message } of readDataFile(program, context.filename).problems) {
28
+ context.report({ message, node: { range: [start, end] } });
29
+ }
30
+ },
31
+ };
32
+ },
33
+ },
34
+ },
35
+ };
36
+
37
+ export default plugin;
package/src/policy.ts ADDED
@@ -0,0 +1,139 @@
1
+ import { z } from "zod";
2
+ import { type ApprovalState, Approver, Covers, type Principal, sameApprover } from "./define.ts";
3
+ import type { Effect, Op } from "./op.ts";
4
+
5
+ /** An operation as a policy sees it: plain data, without its schemas. */
6
+ export type PolicyOp = Pick<Op, "id" | "vendor" | "resource" | "name" | "effect">;
7
+
8
+ /**
9
+ * An operation as a policy sees it, for a `PolicyCall`: what the runtime passes, so a policy
10
+ * test can build a call from the connector, `{ op: policyOpOf(shop.order.refund), ... }`.
11
+ */
12
+ export const policyOpOf = ({ id, vendor, resource, name, effect }: Op): PolicyOp => ({
13
+ id,
14
+ vendor,
15
+ resource,
16
+ name,
17
+ effect,
18
+ });
19
+
20
+ /** What a policy sees for each operation a run calls. Plain data, so a test can build one by hand. */
21
+ export interface PolicyCall {
22
+ op: PolicyOp;
23
+ /** The same as `op.effect`. */
24
+ effect: Effect;
25
+ /** The resource instance the call acts on, from the operation's `target`, when it declares one. */
26
+ target?: string;
27
+ /** The operation's input, already checked against its schema. */
28
+ input: unknown;
29
+ /** Who started the run. */
30
+ actor: Principal;
31
+ /**
32
+ * The run so far: a copy of its approvals, including those the policy asked for earlier.
33
+ * A policy request has `requestedBy: "policy"` and the `op` it held.
34
+ */
35
+ run: { id: string; workflow: string; approvals: readonly ApprovalState[] };
36
+ }
37
+
38
+ /**
39
+ * A policy's answer. `reasons` say why, for whoever reads the ledger; they change nothing
40
+ * about what happens to the call.
41
+ */
42
+ export type Decision =
43
+ | { kind: "allow"; reasons?: string[] }
44
+ | { kind: "deny"; reason: string; reasons?: string[] }
45
+ | ApproveDecision;
46
+
47
+ /**
48
+ * Hold the call until the approver decides: a person's id, or `{ group }` for anyone in it.
49
+ * The approval covers the held operation, and `covers` adds others, so a policy can let their
50
+ * later calls through once it is approved.
51
+ */
52
+ interface ApproveDecision {
53
+ kind: "approve";
54
+ approver: Approver;
55
+ title?: string;
56
+ covers?: Op[];
57
+ }
58
+
59
+ const ReasonList = z.array(z.string(), { error: "reasons must be a list of strings" }).optional();
60
+
61
+ /**
62
+ * Checks a policy's answer. Parsing also copies it, so nothing else the policy returned is kept,
63
+ * and turns `covers` into op ids.
64
+ */
65
+ export const DecisionSchema = z.discriminatedUnion(
66
+ "kind",
67
+ [
68
+ z.object({ kind: z.literal("allow"), reasons: ReasonList }),
69
+ z.object({ kind: z.literal("deny"), reason: z.string({ error: "deny needs a reason" }), reasons: ReasonList }),
70
+ z.object({
71
+ kind: z.literal("approve"),
72
+ approver: Approver,
73
+ title: z.string({ error: "the title must be a string" }).optional(),
74
+ covers: Covers,
75
+ }),
76
+ ],
77
+ { error: "not a decision" },
78
+ );
79
+
80
+ /**
81
+ * A decision as checked and as the ledger records it: `covers` by op id, and the `version` of
82
+ * the policy that made it, when it has one.
83
+ */
84
+ export type RecordedDecision = z.output<typeof DecisionSchema> & { policyVersion?: string };
85
+
86
+ export const allow = (reasons?: string[]): Decision =>
87
+ reasons === undefined ? { kind: "allow" } : { kind: "allow", reasons };
88
+ export const deny = (reason: string, reasons?: string[]): Decision =>
89
+ reasons === undefined ? { kind: "deny", reason } : { kind: "deny", reason, reasons };
90
+
91
+ /** Holds the call for `approver`. `covers` lists other operations the approval also stands for. */
92
+ export function approve(approver: Approver, { title, covers }: { title?: string; covers?: Op[] } = {}): Decision {
93
+ return {
94
+ kind: "approve",
95
+ approver,
96
+ ...(title === undefined ? {} : { title }),
97
+ ...(covers === undefined ? {} : { covers }),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Decides whether a run may make an operation call: allow it, deny it (the run fails),
103
+ * or hold it until someone approves. Runs are replayed after a restart, so a policy must
104
+ * decide the same way every time from the call alone: no clock, randomness or network.
105
+ * `lintWorkflow` checks a policy file for these too. Each decision is recorded once made,
106
+ * and a replay reuses it, but a call interrupted before that is decided again. A policy
107
+ * must not have side effects.
108
+ */
109
+ export type Policy = ((call: PolicyCall) => Decision | Promise<Decision>) & {
110
+ /** Names this version of the policy, from `definePolicy(fn, { version })`. */
111
+ readonly version?: string;
112
+ };
113
+
114
+ /** A policy, optionally named with a version so a reader can tell which rules decided a call. */
115
+ export function definePolicy(
116
+ policy: (call: PolicyCall) => Decision | Promise<Decision>,
117
+ options: { version?: string } = {},
118
+ ): Policy {
119
+ if (typeof policy !== "function") throw new Error("definePolicy expects a function of the call");
120
+ if (options.version !== undefined && (typeof options.version !== "string" || !options.version.trim())) {
121
+ throw new Error("definePolicy: `version` must be a non-empty string");
122
+ }
123
+ const defined = (call: PolicyCall) => policy(call);
124
+ return Object.freeze(options.version === undefined ? defined : Object.assign(defined, { version: options.version }));
125
+ }
126
+
127
+ /** Allows every operation call. A config says so explicitly: `policy: allowAll`. */
128
+ export const allowAll: Policy = definePolicy(() => allow());
129
+
130
+ /**
131
+ * True when an approval in the run was approved, covers the operation, and was addressed to
132
+ * `approver`. Check all three: a workflow can request an approval covering any operation from
133
+ * anyone it names, so an approval's `covers` alone does not say the right person approved.
134
+ */
135
+ export function approvedFor(approvals: readonly ApprovalState[], opId: string, approver: Approver): boolean {
136
+ return approvals.some(
137
+ (a) => a.status === "approved" && a.covers.includes(opId) && sameApprover(a.approver, approver),
138
+ );
139
+ }
@@ -0,0 +1,447 @@
1
+ import { z } from "zod";
2
+ import { type Connector, type OpSpec, RESOURCE, VENDOR } from "./op.ts";
3
+
4
+ /** Which fields of a resource are flagged, by dotted path (`pages.html_url`; a list's items have no index). */
5
+ export interface ResourceFields {
6
+ /** Changing it replaces the object. */
7
+ readonly immutable: readonly string[];
8
+ /** Set by the vendor only: never drift, and a data file may not declare it. */
9
+ readonly vendorOwned: readonly string[];
10
+ /** Sensitive or write-only: never read back, never compared, never in an operation's output. */
11
+ readonly writeOnly: readonly string[];
12
+ /** Lists whose order is the vendor's (a set): compared as multisets, in no order. */
13
+ readonly unordered?: readonly string[];
14
+ /**
15
+ * Fields that name another declared resource, by dotted path: the `<vendor>.<type>` it is, and
16
+ * how the vendor spells it (`by`): `{ repository_id: { type: "github.repository", by: ["name",
17
+ * "node_id"] } }`. A data file gives that resource there (`repository_id: site`), which stands
18
+ * for its `name`; anywhere else a resource is refused.
19
+ */
20
+ readonly references?: References;
21
+ }
22
+
23
+ /** Where a field names another declared resource: see `ResourceFields.references`. */
24
+ export interface FieldReference {
25
+ /** The `<vendor>.<type>` of the resource it names. */
26
+ readonly type: string;
27
+ /**
28
+ * The fields of that resource's state the vendor may hold here instead of its `name`, such as
29
+ * GitHub's `node_id`: a drift check takes any of their values, read in the same run, as the
30
+ * declared one. Without it, the field holds the resource's `name`, and is compared as it is.
31
+ */
32
+ readonly by?: readonly string[];
33
+ }
34
+
35
+ /** A resource type's `fields.references`: by dotted path, the resource each names. */
36
+ export type References = { readonly [path: string]: FieldReference };
37
+
38
+ type State = Record<string, unknown>;
39
+
40
+ /** A resource type, as its connector declares it with `defineResource`. */
41
+ export interface ResourceSpec<S extends z.ZodObject = z.ZodObject> {
42
+ /** The connector's vendor id, such as `github`; `defineConnector` refuses a type of another vendor. */
43
+ vendor: string;
44
+ /** The type's name within the vendor, such as `repository`: its operations are `<vendor>.<type>.read` and `.import`. */
45
+ type: string;
46
+ /** What people call it, such as "Repository". */
47
+ title: string;
48
+ /**
49
+ * How `find` makes the import id, for people: `name`, `repository_id:pattern`. The fields it
50
+ * names stay declarable even when the vendor sets them (Stripe's `id`).
51
+ */
52
+ identity: string;
53
+ /** The resource's fields as a read returns them; a data file declares them, less the vendor-owned ones. */
54
+ schema: S;
55
+ fields: ResourceFields;
56
+ /** The vendor's id for a declared resource (the import id), from its declared fields. */
57
+ find: (desired: z.input<S>) => string;
58
+ /**
59
+ * The part of a state that a drift check compares, given what was declared. The check
60
+ * compares `normalize(actual, desired)` with `normalize(desired, desired)`. Default:
61
+ * `compareDeclared`, which keeps the declared fields only.
62
+ */
63
+ normalize?: (state: State, desired: z.input<S>) => State;
64
+ }
65
+
66
+ /**
67
+ * Where a declared resource keeps its brand, its type's `<vendor>.<type>`: a symbol, so a field
68
+ * holding a resource is told from data; a registered one (`Symbol.for`), so a copy of this
69
+ * package reads what another copy declared, as `VENDOR`.
70
+ */
71
+ export const DECLARED: unique symbol = Symbol.for("sanoma.declared");
72
+
73
+ /** A resource a data file declares: `github.repository({ name: "sanoma" })`. */
74
+ export interface Declared<Id extends string = string, T = State> {
75
+ readonly [DECLARED]: Id;
76
+ readonly vendor: string;
77
+ readonly type: string;
78
+ /** The vendor's id for it: what `find` made of `desired`. */
79
+ readonly name: string;
80
+ /**
81
+ * Its fields, each resource it names replaced by that resource's `name`: what its type
82
+ * checked, and what a drift check compares.
83
+ */
84
+ readonly desired: T;
85
+ /**
86
+ * Where it names another resource: the field's dotted path (a list item's with its index,
87
+ * `teams.0`) to that resource's `<vendor>.<type>:<name>`.
88
+ */
89
+ readonly refs: Readonly<Record<string, string>>;
90
+ }
91
+
92
+ /**
93
+ * What a resource type's constructor takes: its fields, where `R` (its `fields.references`)
94
+ * names a path, a declared resource of that type instead of a value (`repository_id: site`).
95
+ */
96
+ export type Declaring<T, R extends References = {}> = [keyof R] extends [never] ? T : DeclaringAt<T, R, "">;
97
+
98
+ type DeclaringAt<T, R extends References, P extends string> = T extends readonly (infer I)[]
99
+ ? DeclaringAt<I, R, P>[]
100
+ : T extends object
101
+ ? { [K in keyof T]: K extends string ? DeclaringField<T[K], R, P extends "" ? K : `${P}.${K}`> : T[K] }
102
+ : T;
103
+
104
+ type DeclaringField<V, R extends References, P extends string> = P extends keyof R
105
+ ? V | DeclaredOf<R[P]["type"]>
106
+ : [Extract<keyof R, `${P}.${string}`>] extends [never]
107
+ ? V
108
+ : DeclaringAt<V, R, P>;
109
+
110
+ type DeclaredOf<Id> = Id extends string ? Declared<Id> : never;
111
+
112
+ const importId = z.string().min(1);
113
+ /** What a driver keeps beside a state (an OpenTofu provider's private data and state version), passed back unchanged. */
114
+ const handle = z.string().optional().describe("The driver's data for the object, opaque: pass it back unchanged");
115
+
116
+ const opsOf = <S extends z.ZodObject>(schema: S) => {
117
+ // Both answer `{ gone: true }` when the vendor has no such object, and its state when it has.
118
+ const output = z.object({ id: importId, gone: z.boolean(), state: schema.optional(), handle });
119
+ return {
120
+ import: { input: z.object({ id: importId }), output },
121
+ read: { input: z.object({ id: importId, state: z.record(z.string(), z.unknown()).optional(), handle }), output },
122
+ };
123
+ };
124
+
125
+ /** What the ledger keeps of a resource operation's `handle`: its name, never its bytes. */
126
+ const opaque = ["handle"] as const;
127
+
128
+ /** The policy's `target` for a resource's operations: the import id. */
129
+ const target = ({ id }: { id: string }) => id;
130
+
131
+ type OpsOf<S extends z.ZodObject> = ReturnType<typeof opsOf<S>>;
132
+
133
+ /** A resource type's operation specs: `read` and `import`. */
134
+ export type ResourceOps<S extends z.ZodObject> = {
135
+ [N in keyof OpsOf<S>]: OpSpec<OpsOf<S>[N]["input"], OpsOf<S>[N]["output"]>;
136
+ };
137
+
138
+ /**
139
+ * A resource type: call it to declare a resource in a data file, and give it to
140
+ * `defineConnector` as a group, under its `type`: `defineConnector("github", { repository })`.
141
+ */
142
+ export interface Resource<
143
+ S extends z.ZodObject = z.ZodObject,
144
+ Id extends string = string,
145
+ R extends References = {},
146
+ > extends Readonly<Required<ResourceSpec<S>>> {
147
+ (desired: Declaring<z.input<S>, R>): Declared<Id, z.input<S>>;
148
+ /** `read` and `import`, each effect `read`, idempotent, with the import id as `target`. */
149
+ readonly ops: ResourceOps<S>;
150
+ readonly [RESOURCE]: true;
151
+ }
152
+
153
+ /** True for a plain object: not null, not a list. */
154
+ export const isObject = (v: unknown): v is State => typeof v === "object" && v !== null && !Array.isArray(v);
155
+
156
+ /** `path.name`, or `name` at the top: a field's dotted path. */
157
+ export const joinPath = (path: string, name: string) => (path ? `${path}.${name}` : name);
158
+
159
+ const isDeclared = (v: unknown): v is Declared => isObject(v) && typeof (v as Partial<Declared>)[DECLARED] === "string";
160
+
161
+ /** One order for a list compared as a multiset: by each item's JSON. */
162
+ const canonical = (items: unknown[]) =>
163
+ items
164
+ .map((item) => [JSON.stringify(item) ?? "", item] as const)
165
+ .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
166
+ .map(([, item]) => item);
167
+
168
+ interface Comparer {
169
+ pick: (state: State, desired: State) => State;
170
+ /** `fields.unordered`, as a set. */
171
+ unordered: ReadonlySet<string>;
172
+ }
173
+
174
+ const comparers = new WeakMap<ResourceFields, Comparer>();
175
+
176
+ /** The type's comparer, made once per `fields`. */
177
+ function comparerOf(fields: ResourceFields): Comparer {
178
+ let compare = comparers.get(fields);
179
+ if (!compare) {
180
+ compare = comparer(fields);
181
+ comparers.set(fields, compare);
182
+ }
183
+ return compare;
184
+ }
185
+
186
+ /**
187
+ * The fields of `state` that `desired` declares, minus vendor-owned and write-only ones: only
188
+ * declared fields are compared, as CloudFormation does. An object is picked field by field, a
189
+ * list item by item against the declared item at its index, and an `unordered` list (a set)
190
+ * item by item against what its declared items declare, then sorted, so its order never
191
+ * drifts. Anything else is kept whole.
192
+ *
193
+ * So a field the data file leaves out is never drift, whatever the vendor holds. That covers
194
+ * attributes the provider marks `computed` and `optional`: they are the user's to set, not
195
+ * vendor-owned, but the vendor fills them when nobody does (GitHub's `etag`, `topics`,
196
+ * `visibility`), so they are compared only when declared.
197
+ */
198
+ export function compareDeclared(fields: ResourceFields, state: State, desired: State): State {
199
+ return comparerOf(fields).pick(state, desired);
200
+ }
201
+
202
+ function comparer(fields: ResourceFields): Comparer {
203
+ const skip = new Set([...fields.vendorOwned, ...fields.writeOnly]);
204
+ const unordered = new Set(fields.unordered);
205
+ const pick = (actual: unknown, declared: unknown, path: string): unknown => {
206
+ if (Array.isArray(declared) && Array.isArray(actual)) {
207
+ if (unordered.has(path)) {
208
+ // What any declared item declares: a set's items have no declared counterpart by index.
209
+ const objects = declared.filter(isObject);
210
+ const like = objects.length ? Object.assign({}, ...objects) : undefined;
211
+ return canonical(like ? actual.map((item) => pick(item, like, path)) : actual);
212
+ }
213
+ return actual.map((item, i) => (i < declared.length ? pick(item, declared[i], path) : item));
214
+ }
215
+ if (!isObject(declared) || !isObject(actual)) return actual ?? null;
216
+ const out: State = {};
217
+ for (const [name, value] of Object.entries(declared)) {
218
+ const at = joinPath(path, name);
219
+ if (value === undefined || skip.has(at)) continue;
220
+ out[name] = pick(actual[name], value, at);
221
+ }
222
+ return out;
223
+ };
224
+ return { pick: (state, desired) => pick(state, desired, "") as State, unordered };
225
+ }
226
+
227
+ /** A declared field the vendor holds another value in. */
228
+ export interface DriftField {
229
+ /** Its dotted path, a list item's with its index: `required_pull_request_reviews.0.dismiss_stale_reviews`. */
230
+ path: string;
231
+ /** What the data file declares. */
232
+ desired: unknown;
233
+ /** What the vendor holds, `null` for nothing. */
234
+ actual: unknown;
235
+ }
236
+
237
+ /**
238
+ * Where `state` differs from what `desired` declares: the type's `normalize` of each (by default
239
+ * `compareDeclared`: declared fields only, never vendor-owned or write-only ones, `unordered`
240
+ * lists as sets), then leaf by leaf: objects field by field, lists of one length item by item,
241
+ * and anything else (a list whose length changed, an `unordered` one) whole.
242
+ *
243
+ * `accept` is, by a reference's path as `refs` has it (`repository_id`, `teams.0`), the values
244
+ * the vendor may hold there for the resource it names (its `node_id`, say: see
245
+ * `FieldReference.by`): any of them counts as the declared value.
246
+ */
247
+ export function diffDeclared(
248
+ // `any`: a type's `normalize` takes its own declared fields.
249
+ resource: { readonly fields: ResourceFields; readonly normalize: (state: State, desired: any) => State },
250
+ state: State,
251
+ desired: State,
252
+ accept: Readonly<Record<string, readonly unknown[]>> = {},
253
+ ): DriftField[] {
254
+ const actual = withAccepted(state, desired, accept);
255
+ const { unordered } = comparerOf(resource.fields);
256
+ return changes(resource.normalize(desired, desired), resource.normalize(actual, desired), unordered);
257
+ }
258
+
259
+ /** `state`, with each value `accept` takes at its path replaced by the declared one there. */
260
+ function withAccepted(state: State, desired: State, accept: Readonly<Record<string, readonly unknown[]>>): State {
261
+ const paths = Object.entries(accept).filter(([, values]) => values.length);
262
+ if (!paths.length) return state;
263
+ const out = structuredClone(state);
264
+ for (const [path, values] of paths) {
265
+ const keys = path.split(".");
266
+ const last = keys.pop()!;
267
+ const holder = keys.reduce<unknown>(
268
+ (v, key) => (isObject(v) || Array.isArray(v) ? (v as State)[key] : undefined),
269
+ out,
270
+ );
271
+ if (!isObject(holder) && !Array.isArray(holder)) continue;
272
+ const at = holder as State;
273
+ if (values.includes(at[last])) at[last] = valueAt(desired, path);
274
+ }
275
+ return out;
276
+ }
277
+
278
+ /** The value at a dotted path, a list item's by its index. */
279
+ const valueAt = (value: unknown, path: string) =>
280
+ path.split(".").reduce<unknown>((v, key) => (isObject(v) || Array.isArray(v) ? (v as State)[key] : undefined), value);
281
+
282
+ /** True when the two are the same JSON, where a missing value is `null`. */
283
+ function same(a: unknown, b: unknown): boolean {
284
+ if (a === b || (a == null && b == null)) return true;
285
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((item, i) => same(item, b[i]));
286
+ if (isObject(a) && isObject(b)) return Object.keys({ ...a, ...b }).every((key) => same(a[key], b[key]));
287
+ return false;
288
+ }
289
+
290
+ /**
291
+ * Where `actual` differs from `desired`, leaf by leaf. `path` names the field as `fields` does,
292
+ * without indexes; `at` as a report does, with them.
293
+ */
294
+ function changes(desired: unknown, actual: unknown, unordered: ReadonlySet<string>, path = "", at = ""): DriftField[] {
295
+ if (isObject(desired) && isObject(actual)) {
296
+ return Object.keys({ ...desired, ...actual }).flatMap((key) =>
297
+ changes(desired[key], actual[key], unordered, joinPath(path, key), joinPath(at, key)),
298
+ );
299
+ }
300
+ if (Array.isArray(desired) && Array.isArray(actual) && desired.length === actual.length && !unordered.has(path)) {
301
+ return desired.flatMap((item, i) => changes(item, actual[i], unordered, path, joinPath(at, String(i))));
302
+ }
303
+ return same(desired, actual) ? [] : [{ path: at, desired: desired ?? null, actual: actual ?? null }];
304
+ }
305
+
306
+ /**
307
+ * `desired` without the fields the type marks write-only (a webhook's `secret`): what a run may
308
+ * record of a declaration. A list's items lose them too.
309
+ */
310
+ export function withoutWriteOnly(fields: ResourceFields, desired: State): State {
311
+ if (!fields.writeOnly.length) return desired;
312
+ const drop = new Set(fields.writeOnly);
313
+ const strip = (value: unknown, path: string): unknown => {
314
+ if (Array.isArray(value)) return value.map((item) => strip(item, path));
315
+ if (!isObject(value)) return value;
316
+ return Object.fromEntries(
317
+ Object.entries(value).flatMap(([name, v]) => {
318
+ const at = joinPath(path, name);
319
+ return drop.has(at) ? [] : [[name, strip(v, at)]];
320
+ }),
321
+ );
322
+ };
323
+ return strip(desired, "") as State;
324
+ }
325
+
326
+ /** The dotted paths in `value` that `paths` holds, looking into objects and lists' items. */
327
+ function pathsIn(value: unknown, paths: ReadonlySet<string>, prefix = ""): string[] {
328
+ if (Array.isArray(value)) return [...new Set(value.flatMap((item) => pathsIn(item, paths, prefix)))];
329
+ if (!isObject(value)) return [];
330
+ return Object.entries(value).flatMap(([name, v]) => {
331
+ const path = joinPath(prefix, name);
332
+ if (v === undefined) return [];
333
+ return paths.has(path) ? [path] : pathsIn(v, paths, path);
334
+ });
335
+ }
336
+
337
+ /**
338
+ * Declares a resource type: its schema, flagged fields, identity and how it is compared, and
339
+ * derives its operations, `<vendor>.<type>.read` and `<vendor>.<type>.import`, both effect
340
+ * `read` and idempotent, with the import id as the policy's `target`:
341
+ *
342
+ * - `import` takes `{ id }`, the import id, and returns `{ gone: false, state }`, the object's
343
+ * state (with the driver's opaque `handle` when it keeps one), or `{ gone: true }` when the
344
+ * vendor has no such object.
345
+ * - `read` takes `{ id, state?, handle? }`, a state from an earlier `import` or `read`, and
346
+ * returns the same: `{ gone: true }` when the object no longer exists, else its fresh
347
+ * `state`. A driver may import first when it is given no state.
348
+ *
349
+ * The ledger records a `handle` as `"<handle>"`, never its bytes (the operations' `opaque`).
350
+ *
351
+ * Calling the result declares one resource, for a data file: `repository({ name: "sanoma" })`
352
+ * is a `Declared`, `{ vendor, type, name: "sanoma", desired, refs: {} }`. It refuses fields the
353
+ * schema does not have, fields the vendor owns (unless the identity names them), values the
354
+ * schema rejects, and an empty identity. Where `fields.references` allows it, a field may name
355
+ * another declared resource of that type (`repository_id: site`): `desired` holds, and the
356
+ * schema and `find` read, that resource's `name`, and `refs` says where it was named.
357
+ */
358
+ export function defineResource<
359
+ S extends z.ZodObject,
360
+ const V extends string,
361
+ const T extends string,
362
+ const R extends References = {},
363
+ >(spec: ResourceSpec<S> & { vendor: V; type: T; fields: { references?: R } }): Resource<S, `${V}.${T}`, R> {
364
+ const { vendor, type, schema, fields, find } = spec;
365
+ const what: `${V}.${T}` = `${vendor}.${type}`;
366
+ const io = opsOf(schema);
367
+ const ops = {
368
+ import: {
369
+ effect: "read",
370
+ idempotent: true,
371
+ description: `Find a ${spec.title.toLowerCase()} by its ${spec.identity} and read it; gone when there is none`,
372
+ ...io.import,
373
+ target,
374
+ opaque,
375
+ },
376
+ read: {
377
+ effect: "read",
378
+ idempotent: true,
379
+ description: `Read a ${spec.title.toLowerCase()} as it is now; gone when it no longer exists`,
380
+ ...io.read,
381
+ target,
382
+ opaque,
383
+ },
384
+ } satisfies ResourceOps<S>;
385
+
386
+ const named = new Set(spec.identity.match(/[A-Za-z_][\w.]*/g));
387
+ const owned = new Set(fields.vendorOwned.filter((path) => !named.has(path)));
388
+ const references: References = fields.references ?? {};
389
+ const declare = (given: Declaring<z.input<S>, R>): Declared<`${V}.${T}`, z.input<S>> => {
390
+ const refs: Record<string, string> = {};
391
+ // Each resource `given` names, where `references` allows one of its type, replaced by its name.
392
+ // `path` is the field's, as `references` names it; `at` has list items' indexes too.
393
+ const resolve = (value: unknown, path: string, at: string): unknown => {
394
+ if (isDeclared(value)) {
395
+ const id = value[DECLARED];
396
+ const wants = references[path];
397
+ if (wants === undefined) throw new Error(`${what}: ${at} takes a value, not a resource (${id} ${value.name})`);
398
+ if (wants.type !== id)
399
+ throw new Error(`${what}: ${at} names a resource of type ${wants.type}, not ${id} (${value.name})`);
400
+ refs[at] = `${id}:${value.name}`;
401
+ return value.name;
402
+ }
403
+ if (Array.isArray(value)) return value.map((item, i) => resolve(item, path, `${at}.${i}`));
404
+ if (!isObject(value)) return value;
405
+ return Object.fromEntries(
406
+ Object.entries(value).map(([field, v]) => [field, resolve(v, joinPath(path, field), joinPath(at, field))]),
407
+ );
408
+ };
409
+ const desired = resolve(given, "", "") as z.input<S>;
410
+ const unknown = Object.keys(desired ?? {}).filter((name) => !Object.hasOwn(schema.shape, name));
411
+ if (unknown.length) throw new Error(`${what}: no field ${unknown.join(", ")}`);
412
+ const vendors = pathsIn(desired, owned);
413
+ if (vendors.length) {
414
+ throw new Error(
415
+ `${what}: leave out ${vendors.join(", ")}: the vendor sets ${vendors.length > 1 ? "them" : "it"}`,
416
+ );
417
+ }
418
+ const parsed = schema.safeParse(desired);
419
+ if (!parsed.success) throw new Error(`${what}: ${z.prettifyError(parsed.error)}`);
420
+ const name = find(desired);
421
+ if (typeof name !== "string" || name === "") throw new Error(`${what}: its ${spec.identity} is missing`);
422
+ return Object.freeze({ [DECLARED]: what, vendor, type, name, desired, refs: Object.freeze(refs) });
423
+ };
424
+ const resource = Object.assign(declare, {
425
+ vendor,
426
+ type,
427
+ title: spec.title,
428
+ identity: spec.identity,
429
+ schema,
430
+ fields,
431
+ find,
432
+ normalize: spec.normalize ?? ((state: State, desired: z.input<S>) => compareDeclared(fields, state, desired)),
433
+ ops,
434
+ // A symbol, so `defineConnector` tells a resource type from a group of operations.
435
+ [RESOURCE]: true as const,
436
+ });
437
+ return Object.freeze(resource);
438
+ }
439
+
440
+ /** Every resource type the connectors declare, by `<vendor>.<type>`. */
441
+ export function resourceTypesOf(connectors: readonly Connector<any, any>[]): Map<string, Resource> {
442
+ const types = new Map<string, Resource>();
443
+ for (const connector of connectors) {
444
+ for (const r of connector[VENDOR].resources as readonly Resource[]) types.set(`${r.vendor}.${r.type}`, r);
445
+ }
446
+ return types;
447
+ }