@kedge/cli 0.0.0-stage → 0.10.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.
@@ -0,0 +1,145 @@
1
+ // Lints everything a plugin will run as SQL, not only its migration files:
2
+ // acquire bodies, checkout reads and statements, and release handlers. The
3
+ // functions that build runtime SQL are called with a representative input,
4
+ // since the linter needs their text, not their values.
5
+ import type { PluginDefinition, ScheduleDescriptor, SqlStatement } from "@kedge/core";
6
+ import {
7
+ entityTable,
8
+ entityWriteProblems,
9
+ errorSources,
10
+ isTimerDescriptor,
11
+ type LintIssue,
12
+ type LintOptions,
13
+ lintSql,
14
+ type Owner,
15
+ trackSql,
16
+ } from "@kedge/core";
17
+
18
+ type Context = LintOptions["context"];
19
+
20
+ const sample = {
21
+ cartId: "cart_lint",
22
+ email: "lint@example.com",
23
+ customerId: "cus_lint",
24
+ currency: "USD",
25
+ variantIds: ["var_lint"],
26
+ };
27
+
28
+ /** The SQL a plugin builds at runtime, from sample inputs, with where each came from. */
29
+ export function collect<O>(def: PluginDefinition<O>, options: O): { sql: string; context: Context; where: string }[] {
30
+ const out: { sql: string; context: Context; where: string }[] = [];
31
+ // Timer descriptors beside a contribution's SQL are core's to write (p0.6a §3.2): nothing to lint.
32
+ const push = (where: string, context: Context, ss: readonly (SqlStatement | ScheduleDescriptor)[] | undefined) => {
33
+ for (const s of ss ?? []) if (!isTimerDescriptor(s)) out.push({ sql: s.sql, context, where });
34
+ };
35
+ for (const [key, body] of Object.entries(def.acquire ?? {}))
36
+ out.push({ sql: body as string, context: "acquire", where: `acquire ${key}` });
37
+ out.push(...trackSql(def, options));
38
+ const c = def.checkout;
39
+ if (c?.reads) push("checkout.reads", "read", Object.values(c.reads(sample, options)));
40
+ if (c?.statements) {
41
+ // Cite every declared fact under a sample key, so conditional statements are produced.
42
+ const facts = Object.fromEntries(
43
+ Object.keys(c.facts ?? {}).map((n) => [n, new Proxy({}, { get: () => "cfa_lint" })]),
44
+ );
45
+ push(
46
+ "checkout.statements",
47
+ "contribution",
48
+ c.statements({
49
+ orderId: "ord_lint",
50
+ email: sample.email,
51
+ customerId: sample.customerId,
52
+ currency: sample.currency,
53
+ options,
54
+ claim: {},
55
+ lines: [
56
+ {
57
+ id: "ordl_lint",
58
+ variantId: "var_lint",
59
+ sku: "S",
60
+ title: "T",
61
+ quantity: 1,
62
+ unitAmount: 100,
63
+ lineTotal: 100,
64
+ kind: "item",
65
+ parentId: null,
66
+ chargeClass: "revenue",
67
+ taxable: true,
68
+ discountable: true,
69
+ periodStart: null,
70
+ periodEnd: null,
71
+ custom: {},
72
+ },
73
+ ],
74
+ facts,
75
+ fees: [{ code: "lint", amount: 1 }],
76
+ adjustments: [{ code: "lint", amount: -1 }],
77
+ }),
78
+ );
79
+ }
80
+ for (const [key, handler] of Object.entries(def.release ?? {})) {
81
+ if (!handler) continue;
82
+ push(
83
+ `release ${key}`,
84
+ "statement",
85
+ handler(
86
+ {
87
+ uid: "ord_lint:expired",
88
+ aggregate: "order",
89
+ aggregateId: "ord_lint",
90
+ fromState: "pending_payment",
91
+ toState: "expired",
92
+ },
93
+ options,
94
+ ),
95
+ );
96
+ }
97
+ return out;
98
+ }
99
+
100
+ export function lintPlugin<O>(def: PluginDefinition<O>, options: O): LintIssue[] {
101
+ const owner: Owner = { kind: "plugin", name: def.name };
102
+ const issues: LintIssue[] = [];
103
+ const texts: string[] = [];
104
+ for (const m of def.migrations ?? []) {
105
+ texts.push(m.sql);
106
+ for (const i of lintSql(owner, m.sql, { context: "migration" })) issues.push({ ...i, at: `${m.id}: ${i.at}` });
107
+ }
108
+ let runtime: ReturnType<typeof collect>;
109
+ try {
110
+ runtime = collect(def, options);
111
+ } catch (e) {
112
+ issues.push({
113
+ rule: "unlintable",
114
+ message: `building the plugin's SQL with sample input threw: ${(e as Error).message}`,
115
+ at: def.name,
116
+ });
117
+ runtime = [];
118
+ }
119
+ for (const r of runtime) {
120
+ texts.push(r.sql);
121
+ const factTables = Object.values(def.checkout?.facts ?? {}).map((f) => f.table);
122
+ for (const i of lintSql(owner, r.sql, { context: r.context, factTables }))
123
+ issues.push({ ...i, at: `${r.where}: ${i.at}` });
124
+ }
125
+ // Only core writes an entity table (§4.2), from every SQL source a plugin has:
126
+ // a migration's trigger, an acquire body core installs as a trigger, a
127
+ // consequence, a release (codex code r6).
128
+ const entityTables = Object.keys(def.entities ?? {}).map((e) => entityTable(def.name, e));
129
+ const sources = [
130
+ ...(def.migrations ?? []).map((m) => ({ where: `migration ${m.id}`, sql: m.sql })),
131
+ ...runtime.map((r) => ({ where: r.where, sql: r.sql })),
132
+ ];
133
+ for (const s of sources)
134
+ for (const message of entityWriteProblems(s.sql, entityTables))
135
+ issues.push({ rule: "entity-write", message, at: s.where });
136
+
137
+ // The error-map check: every named CHECK and RAISE text maps to a code.
138
+ const mapped = new Set(Object.keys(def.errors ?? {}));
139
+ const { checks, raises } = errorSources(texts.join(";\n"));
140
+ for (const key of [...checks, ...raises]) {
141
+ if (!mapped.has(key))
142
+ issues.push({ rule: "error-map", message: `"${key}" has no entry in the plugin's error map`, at: def.name });
143
+ }
144
+ return issues;
145
+ }