@lunora/codegen 0.0.0 → 1.0.0-alpha.10
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/LICENSE.md +105 -0
- package/README.md +117 -9
- package/__assets__/package-og.svg +14 -0
- package/dist/index.d.mts +1562 -0
- package/dist/index.d.ts +1562 -0
- package/dist/index.mjs +29 -0
- package/dist/packem_shared/CONTAINERS_FILENAME-DlP6YM_Q.mjs +224 -0
- package/dist/packem_shared/CodegenDiagnosticError-DeblMkzO.mjs +23 -0
- package/dist/packem_shared/GENERATED_HEADER-BiFXNUvo.mjs +2692 -0
- package/dist/packem_shared/LUNORA_ERROR_CODES-CySpQPD3.mjs +61 -0
- package/dist/packem_shared/OPENRPC_VERSION-B4i9Qp3v.mjs +60 -0
- package/dist/packem_shared/SCHEMA_SNAPSHOT_FILENAME-Bvv7qa_u.mjs +939 -0
- package/dist/packem_shared/SCHEMA_SNAPSHOT_VERSION-wWGP2_5E.mjs +245 -0
- package/dist/packem_shared/WORKFLOWS_FILENAME-D62dcBGg.mjs +84 -0
- package/dist/packem_shared/buildOpenApiDocument-Cu3mZl-8.mjs +183 -0
- package/dist/packem_shared/discover-ast-CT6BgBr4.mjs +13 -0
- package/dist/packem_shared/discoverAuthApiCalls-CoirYbg6.mjs +62 -0
- package/dist/packem_shared/discoverCrons-Cev7RRAf.mjs +257 -0
- package/dist/packem_shared/discoverFunctions-BWMczzBx.mjs +465 -0
- package/dist/packem_shared/discoverHttpRoutes-CfP6cMzt.mjs +131 -0
- package/dist/packem_shared/discoverInserts-C7zxXkUf.mjs +61 -0
- package/dist/packem_shared/discoverMaskProcedures-Cm81kwrb.mjs +217 -0
- package/dist/packem_shared/discoverMigrations-Bi5nJ0mJ.mjs +97 -0
- package/dist/packem_shared/discoverNondeterministicCalls-C4M8AXmQ.mjs +122 -0
- package/dist/packem_shared/discoverQueries-B0wGT-xe.mjs +62 -0
- package/dist/packem_shared/discoverR2sqlCalls-BVNMd428.mjs +80 -0
- package/dist/packem_shared/discoverRlsMetadata-BS9GOGC5.mjs +280 -0
- package/dist/packem_shared/discoverSchema-BnWHHJ4T.mjs +822 -0
- package/dist/packem_shared/discoverStorageRulesMetadata-Da8BKXcI.mjs +97 -0
- package/dist/packem_shared/emitApp-KfMaKXWe.mjs +603 -0
- package/dist/packem_shared/formatAdvisories-8NIv1k0I.mjs +69 -0
- package/dist/packem_shared/parse-validator-Cabb60UV.mjs +133 -0
- package/dist/packem_shared/paths-BRd6JHuF.mjs +11 -0
- package/dist/packem_shared/schemaFromIr-DTYsLBaA.mjs +57 -0
- package/package.json +45 -17
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
const SCHEMA_SNAPSHOT_VERSION = 1;
|
|
2
|
+
const encodeShardMode = (mode) => {
|
|
3
|
+
if (mode === "global" || mode === "root") {
|
|
4
|
+
return mode;
|
|
5
|
+
}
|
|
6
|
+
return `shardBy:${mode.field}`;
|
|
7
|
+
};
|
|
8
|
+
const fieldSnapshotOf = (validator) => {
|
|
9
|
+
if (validator.kind === "optional") {
|
|
10
|
+
return { kind: validator.inner?.kind ?? "unknown", optional: true };
|
|
11
|
+
}
|
|
12
|
+
return { kind: validator.kind, optional: false };
|
|
13
|
+
};
|
|
14
|
+
const tableSnapshotOf = (table) => {
|
|
15
|
+
const fields = {};
|
|
16
|
+
for (const [name, validator] of Object.entries(table.shape)) {
|
|
17
|
+
fields[name] = fieldSnapshotOf(validator);
|
|
18
|
+
}
|
|
19
|
+
const indexes = {};
|
|
20
|
+
for (const index of table.indexes) {
|
|
21
|
+
indexes[index.name] = { fields: [...index.fields], unique: index.unique ?? false };
|
|
22
|
+
}
|
|
23
|
+
const relations = {};
|
|
24
|
+
for (const relation of table.relations) {
|
|
25
|
+
relations[relation.name] = { field: relation.field, kind: relation.kind, table: relation.table };
|
|
26
|
+
}
|
|
27
|
+
return { fields, indexes, relations, shardMode: encodeShardMode(table.shardMode) };
|
|
28
|
+
};
|
|
29
|
+
const sortKeys = (record) => {
|
|
30
|
+
const sorted = {};
|
|
31
|
+
for (const key of Object.keys(record).toSorted((a, b) => a.localeCompare(b))) {
|
|
32
|
+
sorted[key] = record[key];
|
|
33
|
+
}
|
|
34
|
+
return sorted;
|
|
35
|
+
};
|
|
36
|
+
const buildSchemaSnapshot = (schema, migrationIds) => {
|
|
37
|
+
const tables = {};
|
|
38
|
+
for (const table of schema.tables) {
|
|
39
|
+
tables[table.name] = tableSnapshotOf(table);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
jurisdiction: schema.jurisdiction,
|
|
43
|
+
migrationIds: [...migrationIds].toSorted((a, b) => a.localeCompare(b)),
|
|
44
|
+
tables: sortKeys(tables),
|
|
45
|
+
version: SCHEMA_SNAPSHOT_VERSION
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
const serializeSchemaSnapshot = (snapshot) => `${JSON.stringify(snapshot, void 0, 2)}
|
|
49
|
+
`;
|
|
50
|
+
class SchemaSnapshotParseError extends Error {
|
|
51
|
+
name = "SchemaSnapshotParseError";
|
|
52
|
+
}
|
|
53
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
54
|
+
const isValidTableSnapshot = (value) => isRecord(value) && isRecord(value.fields) && isRecord(value.indexes) && isRecord(value.relations) && typeof value.shardMode === "string";
|
|
55
|
+
const parseSchemaSnapshot = (content) => {
|
|
56
|
+
if (content === void 0 || content.trim() === "") {
|
|
57
|
+
return void 0;
|
|
58
|
+
}
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(content);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw new SchemaSnapshotParseError(`baseline is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
64
|
+
}
|
|
65
|
+
if (!isRecord(parsed) || parsed.version !== SCHEMA_SNAPSHOT_VERSION || !isRecord(parsed.tables)) {
|
|
66
|
+
throw new SchemaSnapshotParseError(`baseline is malformed or written by an incompatible version (expected version ${String(SCHEMA_SNAPSHOT_VERSION)})`);
|
|
67
|
+
}
|
|
68
|
+
for (const [name, table] of Object.entries(parsed.tables)) {
|
|
69
|
+
if (!isValidTableSnapshot(table)) {
|
|
70
|
+
throw new SchemaSnapshotParseError(`baseline table "${name}" has an invalid structure`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const jurisdiction = typeof parsed.jurisdiction === "string" ? parsed.jurisdiction : void 0;
|
|
74
|
+
return {
|
|
75
|
+
jurisdiction,
|
|
76
|
+
migrationIds: Array.isArray(parsed.migrationIds) ? parsed.migrationIds : [],
|
|
77
|
+
tables: parsed.tables,
|
|
78
|
+
version: SCHEMA_SNAPSHOT_VERSION
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
const indexesEqual = (a, b) => a.unique === b.unique && a.fields.length === b.fields.length && a.fields.every((field, index) => field === b.fields[index]);
|
|
82
|
+
const diffExistingField = (tableName, name, old, field) => {
|
|
83
|
+
const changes = [];
|
|
84
|
+
if (old.kind !== field.kind) {
|
|
85
|
+
changes.push({
|
|
86
|
+
severity: "breaking",
|
|
87
|
+
summary: `field ${tableName}.${name} changed type: ${old.kind} → ${field.kind} — add a data migration to convert existing values`,
|
|
88
|
+
type: "changedFieldKind"
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (old.optional && !field.optional) {
|
|
92
|
+
changes.push({
|
|
93
|
+
severity: "breaking",
|
|
94
|
+
summary: `field ${tableName}.${name} became required — rows missing it would be invalid; add a data migration to backfill it`,
|
|
95
|
+
type: "fieldOptionalToRequired"
|
|
96
|
+
});
|
|
97
|
+
} else if (!old.optional && field.optional) {
|
|
98
|
+
changes.push({ severity: "safe", summary: `field ${tableName}.${name} became optional`, type: "fieldRequiredToOptional" });
|
|
99
|
+
}
|
|
100
|
+
return changes;
|
|
101
|
+
};
|
|
102
|
+
const addedFieldChange = (tableName, name, field) => field.optional ? { severity: "safe", summary: `added optional field ${tableName}.${name}`, type: "addedOptionalField" } : {
|
|
103
|
+
severity: "breaking",
|
|
104
|
+
summary: `added required field ${tableName}.${name} — existing rows have no value; add a data migration to backfill it`,
|
|
105
|
+
type: "addedRequiredField"
|
|
106
|
+
};
|
|
107
|
+
const diffFields = (tableName, baseline, current, changes) => {
|
|
108
|
+
for (const [name, field] of Object.entries(current.fields)) {
|
|
109
|
+
const old = baseline.fields[name];
|
|
110
|
+
if (old === void 0) {
|
|
111
|
+
changes.push(addedFieldChange(tableName, name, field));
|
|
112
|
+
} else {
|
|
113
|
+
changes.push(...diffExistingField(tableName, name, old, field));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const name of Object.keys(baseline.fields)) {
|
|
117
|
+
if (current.fields[name] === void 0) {
|
|
118
|
+
changes.push({
|
|
119
|
+
severity: "breaking",
|
|
120
|
+
summary: `removed field ${tableName}.${name} — add a data migration if stored data must be cleaned up`,
|
|
121
|
+
type: "removedField"
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const diffIndexes = (tableName, baseline, current, changes) => {
|
|
127
|
+
for (const [name, index] of Object.entries(current.indexes)) {
|
|
128
|
+
const old = baseline.indexes[name];
|
|
129
|
+
if (old === void 0) {
|
|
130
|
+
changes.push({ severity: "safe", summary: `added index ${name} on ${tableName}`, type: "addedIndex" });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!indexesEqual(old, index)) {
|
|
134
|
+
changes.push({
|
|
135
|
+
severity: "breaking",
|
|
136
|
+
summary: `index ${name} on ${tableName} changed shape — a query may have relied on the old index`,
|
|
137
|
+
type: "changedIndex"
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const name of Object.keys(baseline.indexes)) {
|
|
142
|
+
if (current.indexes[name] === void 0) {
|
|
143
|
+
changes.push({
|
|
144
|
+
severity: "breaking",
|
|
145
|
+
summary: `removed index ${name} on ${tableName} — a query that used \`.withIndex("${name}")\` would break`,
|
|
146
|
+
type: "removedIndex"
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const diffRelations = (tableName, baseline, current, changes) => {
|
|
152
|
+
for (const name of Object.keys(current.relations)) {
|
|
153
|
+
if (baseline.relations[name] === void 0) {
|
|
154
|
+
changes.push({ severity: "safe", summary: `added relation ${tableName}.${name}`, type: "addedRelation" });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const name of Object.keys(baseline.relations)) {
|
|
158
|
+
if (current.relations[name] === void 0) {
|
|
159
|
+
changes.push({ severity: "breaking", summary: `removed relation ${tableName}.${name}`, type: "removedRelation" });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const diffExistingTable = (tableName, baseline, current, changes) => {
|
|
164
|
+
if (baseline.shardMode !== current.shardMode) {
|
|
165
|
+
changes.push({
|
|
166
|
+
severity: "breaking",
|
|
167
|
+
summary: `table ${tableName} changed shard mode: ${baseline.shardMode} → ${current.shardMode} — its physical storage moves; add a data migration / re-shard plan`,
|
|
168
|
+
type: "changedShardMode"
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
diffFields(tableName, baseline, current, changes);
|
|
172
|
+
diffIndexes(tableName, baseline, current, changes);
|
|
173
|
+
diffRelations(tableName, baseline, current, changes);
|
|
174
|
+
};
|
|
175
|
+
const diffSchemaSnapshots = (baseline, current) => {
|
|
176
|
+
const changes = [];
|
|
177
|
+
const baselineTables = baseline?.tables ?? {};
|
|
178
|
+
for (const [tableName, table] of Object.entries(current.tables)) {
|
|
179
|
+
const old = baselineTables[tableName];
|
|
180
|
+
if (old === void 0) {
|
|
181
|
+
changes.push({ severity: "safe", summary: `added table ${tableName}`, type: "addedTable" });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
diffExistingTable(tableName, old, table, changes);
|
|
185
|
+
}
|
|
186
|
+
for (const tableName of Object.keys(baselineTables)) {
|
|
187
|
+
if (current.tables[tableName] === void 0) {
|
|
188
|
+
changes.push({
|
|
189
|
+
severity: "breaking",
|
|
190
|
+
summary: `removed table ${tableName} — add a data migration if its data must be archived/cleaned up`,
|
|
191
|
+
type: "removedTable"
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (baseline !== void 0 && baseline.jurisdiction !== current.jurisdiction) {
|
|
196
|
+
const from = baseline.jurisdiction ?? "(none)";
|
|
197
|
+
const to = current.jurisdiction ?? "(none)";
|
|
198
|
+
changes.push({
|
|
199
|
+
severity: "breaking",
|
|
200
|
+
summary: `Durable Object jurisdiction changed from ${from} to ${to} — this re-homes every DO and strands all existing shard, scheduler, and session-DO data in the old region (no in-place migration; export then import to move it). Revert the change, or override the gate to proceed intentionally.`,
|
|
201
|
+
type: "changedJurisdiction"
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return { changes };
|
|
205
|
+
};
|
|
206
|
+
const evaluateSchemaDrift = (options) => {
|
|
207
|
+
const { allowDrift = false, baseline, current } = options;
|
|
208
|
+
const drift = diffSchemaSnapshots(baseline, current);
|
|
209
|
+
const breaking = drift.changes.filter((change) => change.severity === "breaking");
|
|
210
|
+
const newMigrationIds = current.migrationIds.filter((id) => !(baseline?.migrationIds ?? []).includes(id));
|
|
211
|
+
if (drift.changes.length === 0 || baseline === void 0) {
|
|
212
|
+
return { blocked: false, changes: drift.changes, newMigrationIds, reason: "" };
|
|
213
|
+
}
|
|
214
|
+
if (breaking.length === 0) {
|
|
215
|
+
const summary = drift.changes.map((change) => ` - ${change.summary}`).join("\n");
|
|
216
|
+
return { blocked: false, changes: drift.changes, newMigrationIds, reason: `schema drift detected (all additive/safe):
|
|
217
|
+
${summary}` };
|
|
218
|
+
}
|
|
219
|
+
const breakingSummary = breaking.map((change) => ` - ${change.summary}`).join("\n");
|
|
220
|
+
if (newMigrationIds.length > 0) {
|
|
221
|
+
return {
|
|
222
|
+
blocked: false,
|
|
223
|
+
changes: drift.changes,
|
|
224
|
+
newMigrationIds,
|
|
225
|
+
reason: `breaking schema drift detected, but ${String(newMigrationIds.length)} new migration(s) were added (${newMigrationIds.join(", ")}):
|
|
226
|
+
${breakingSummary}`
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const reason = `deploy blocked: ${String(breaking.length)} breaking schema change(s) detected since the last blessed schema baseline — these changes are incompatible with existing data without a migration:
|
|
230
|
+
${breakingSummary}
|
|
231
|
+
|
|
232
|
+
To fix:
|
|
233
|
+
• For breaking changes: add a \`defineMigration({ id, table, up })\` in lunora/ for each affected table, then run \`lunora migrate\` (or \`lunora codegen\`) to apply and re-bless the baseline.
|
|
234
|
+
• For backward-compatible changes (e.g. adding an optional field): pass \`--allow-schema-drift\` to skip the block.
|
|
235
|
+
• To accept the new shape without a migration (you know data is compatible): pass \`--update-schema-baseline\`.
|
|
236
|
+
Docs: https://lunora.dev/docs/migrations`;
|
|
237
|
+
if (allowDrift) {
|
|
238
|
+
return { blocked: false, changes: drift.changes, newMigrationIds, reason: `${reason}
|
|
239
|
+
|
|
240
|
+
(overridden by --allow-schema-drift)` };
|
|
241
|
+
}
|
|
242
|
+
return { blocked: true, changes: drift.changes, newMigrationIds, reason };
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export { SCHEMA_SNAPSHOT_VERSION, SchemaSnapshotParseError, buildSchemaSnapshot, diffSchemaSnapshots, evaluateSchemaDrift, parseSchemaSnapshot, serializeSchemaSnapshot };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { workflowDefaultName, workflowClassName, workflowBindingName } from '@lunora/workflow';
|
|
4
|
+
import { SyntaxKind, Node } from 'ts-morph';
|
|
5
|
+
import { diagnosticAt } from './CodegenDiagnosticError-DeblMkzO.mjs';
|
|
6
|
+
|
|
7
|
+
const WORKFLOWS_FILENAME = "workflows.ts";
|
|
8
|
+
const isDefineWorkflow = (identifier) => {
|
|
9
|
+
const symbol = identifier.getSymbol();
|
|
10
|
+
if (!symbol) {
|
|
11
|
+
return identifier.getText() === "defineWorkflow";
|
|
12
|
+
}
|
|
13
|
+
for (const declaration of symbol.getDeclarations()) {
|
|
14
|
+
if (!Node.isImportSpecifier(declaration)) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (declaration.getImportDeclaration().getModuleSpecifierValue() !== "@lunora/workflow") {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
return declaration.getNameNode().getText() === "defineWorkflow";
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
};
|
|
24
|
+
const stringProperty = (expression, exportName, property) => {
|
|
25
|
+
if (Node.isStringLiteral(expression) || Node.isNoSubstitutionTemplateLiteral(expression)) {
|
|
26
|
+
return expression.getLiteralValue();
|
|
27
|
+
}
|
|
28
|
+
throw diagnosticAt(
|
|
29
|
+
expression,
|
|
30
|
+
`workflow "${exportName}": \`${property}\` must be a static string literal — it is deploy configuration codegen writes into wrangler.jsonc`
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
const workflowFromCall = (call, exportName) => {
|
|
34
|
+
const argument = call.getArguments()[0];
|
|
35
|
+
if (!argument || !Node.isObjectLiteralExpression(argument)) {
|
|
36
|
+
throw diagnosticAt(call, `workflow "${exportName}": defineWorkflow must be passed an inline object literal`);
|
|
37
|
+
}
|
|
38
|
+
const ir = {
|
|
39
|
+
bindingName: workflowBindingName(exportName),
|
|
40
|
+
className: workflowClassName(exportName),
|
|
41
|
+
exportName,
|
|
42
|
+
name: workflowDefaultName(exportName)
|
|
43
|
+
};
|
|
44
|
+
const nameProperty = argument.getProperty("name");
|
|
45
|
+
if (nameProperty && Node.isPropertyAssignment(nameProperty)) {
|
|
46
|
+
ir.name = stringProperty(nameProperty.getInitializerOrThrow(), exportName, "name");
|
|
47
|
+
}
|
|
48
|
+
return ir;
|
|
49
|
+
};
|
|
50
|
+
const workflowsFromSource = (source) => {
|
|
51
|
+
const workflows = [];
|
|
52
|
+
for (const declaration of source.getVariableDeclarations()) {
|
|
53
|
+
if (!declaration.isExported()) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const initializer = declaration.getInitializer();
|
|
57
|
+
if (initializer?.getKind() !== SyntaxKind.CallExpression) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const call = initializer;
|
|
61
|
+
const callee = call.getExpression();
|
|
62
|
+
if (!Node.isIdentifier(callee) || !isDefineWorkflow(callee)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const nameNode = declaration.getNameNode();
|
|
66
|
+
if (!Node.isIdentifier(nameNode)) {
|
|
67
|
+
throw diagnosticAt(nameNode, "defineWorkflow exports must be plain named exports (no destructuring)");
|
|
68
|
+
}
|
|
69
|
+
workflows.push(workflowFromCall(call, nameNode.getText()));
|
|
70
|
+
}
|
|
71
|
+
return workflows;
|
|
72
|
+
};
|
|
73
|
+
const discoverWorkflows = (project, lunoraDirectory) => {
|
|
74
|
+
const workflowsPath = join(lunoraDirectory, WORKFLOWS_FILENAME);
|
|
75
|
+
if (!existsSync(workflowsPath)) {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const source = project.getSourceFile(workflowsPath) ?? project.addSourceFileAtPath(workflowsPath);
|
|
79
|
+
const workflows = workflowsFromSource(source);
|
|
80
|
+
workflows.sort((a, b) => a.exportName.localeCompare(b.exportName));
|
|
81
|
+
return workflows;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export { WORKFLOWS_FILENAME, discoverWorkflows };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { GENERATED_HEADER } from './GENERATED_HEADER-BiFXNUvo.mjs';
|
|
2
|
+
import { s as sanitizeNamespace } from './paths-BRd6JHuF.mjs';
|
|
3
|
+
import { LUNORA_ERROR_CODES, objectSchema, argsObjectSchema, validatorIrToJsonSchema } from './LUNORA_ERROR_CODES-CySpQPD3.mjs';
|
|
4
|
+
|
|
5
|
+
const ERROR_COMPONENT_REF = "#/components/responses/LunoraError";
|
|
6
|
+
const ROUTE_PARAM_RE = /:([A-Za-z_$][\w$]*)/gu;
|
|
7
|
+
const pathParameterNames = (path) => [...path.matchAll(ROUTE_PARAM_RE)].map((match) => match[1]);
|
|
8
|
+
const toOpenApiPath = (path) => path.replaceAll(ROUTE_PARAM_RE, "{$1}");
|
|
9
|
+
const buildParameters = (route) => {
|
|
10
|
+
const declaredPathNames = new Set(pathParameterNames(route.path));
|
|
11
|
+
const parameters = [];
|
|
12
|
+
for (const [name, validator] of Object.entries(route.searchParams)) {
|
|
13
|
+
const inner = validator.kind === "optional" ? validator.inner ?? validator : validator;
|
|
14
|
+
parameters.push({
|
|
15
|
+
description: `Query parameter \`${name}\``,
|
|
16
|
+
in: "query",
|
|
17
|
+
name,
|
|
18
|
+
required: validator.kind !== "optional",
|
|
19
|
+
schema: validatorIrToJsonSchema(inner)
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
for (const [name, validator] of Object.entries(route.params)) {
|
|
23
|
+
const inner = validator.kind === "optional" ? validator.inner ?? validator : validator;
|
|
24
|
+
parameters.push({
|
|
25
|
+
description: `Path parameter \`${name}\``,
|
|
26
|
+
// A path param the template doesn't name is still surfaced (OpenAPI
|
|
27
|
+
// requires path params to be `required: true`), but flagged so the
|
|
28
|
+
// mismatch is visible rather than silently dropped.
|
|
29
|
+
in: declaredPathNames.has(name) ? "path" : "query",
|
|
30
|
+
name,
|
|
31
|
+
required: declaredPathNames.has(name) ? true : validator.kind !== "optional",
|
|
32
|
+
schema: validatorIrToJsonSchema(inner)
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return parameters;
|
|
36
|
+
};
|
|
37
|
+
const successResponse = (output) => {
|
|
38
|
+
if (output) {
|
|
39
|
+
return {
|
|
40
|
+
content: { "application/json": { schema: validatorIrToJsonSchema(output) } },
|
|
41
|
+
description: "Successful response."
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
content: { "application/json": { schema: { description: "Return shape is TS-inferred (no `.output()` declared); best-effort — any JSON." } } },
|
|
46
|
+
description: "Successful response. The return shape is TypeScript-inferred and not declared via `.output()`, so it is documented best-effort."
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
const httpRouteOperation = (route) => {
|
|
50
|
+
const tag = sanitizeNamespace(route.filePath);
|
|
51
|
+
const parameters = buildParameters(route);
|
|
52
|
+
const operation = {
|
|
53
|
+
description: `${route.stream ? "Streaming (SSE) " : ""}HTTP route handler \`${route.exportName}\` (${route.method} ${route.path}).`,
|
|
54
|
+
operationId: `${route.method.toLowerCase()}_${sanitizeNamespace(route.path)}`,
|
|
55
|
+
responses: {
|
|
56
|
+
"200": successResponse(route.output),
|
|
57
|
+
"204": { description: "No content (handler returned `undefined`)." },
|
|
58
|
+
default: { $ref: ERROR_COMPONENT_REF }
|
|
59
|
+
},
|
|
60
|
+
summary: `${route.method} ${route.path}`,
|
|
61
|
+
tags: [tag]
|
|
62
|
+
};
|
|
63
|
+
if (parameters.length > 0) {
|
|
64
|
+
operation.parameters = parameters;
|
|
65
|
+
}
|
|
66
|
+
if (Object.keys(route.body).length > 0) {
|
|
67
|
+
operation.requestBody = {
|
|
68
|
+
content: { "application/json": { schema: objectSchema(route.body) } },
|
|
69
|
+
required: true
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (route.stream) {
|
|
73
|
+
operation["x-lunora-stream"] = "text/event-stream";
|
|
74
|
+
}
|
|
75
|
+
return operation;
|
|
76
|
+
};
|
|
77
|
+
const rpcOperation = (definition) => {
|
|
78
|
+
const functionPath = `${sanitizeNamespace(definition.filePath)}:${definition.exportName}`;
|
|
79
|
+
const tag = sanitizeNamespace(definition.filePath);
|
|
80
|
+
const requestSchema = {
|
|
81
|
+
additionalProperties: false,
|
|
82
|
+
properties: {
|
|
83
|
+
args: argsObjectSchema(definition.args),
|
|
84
|
+
functionPath: { const: functionPath, type: "string" },
|
|
85
|
+
shardKey: { description: "Optional shard key; omitted routes to the default shard.", type: "string" }
|
|
86
|
+
},
|
|
87
|
+
required: ["functionPath"],
|
|
88
|
+
type: "object"
|
|
89
|
+
};
|
|
90
|
+
const operation = {
|
|
91
|
+
description: `Invoke the \`${definition.kind}\` \`${functionPath}\` over the Lunora RPC envelope (POST /_lunora/rpc).`,
|
|
92
|
+
operationId: functionPath,
|
|
93
|
+
requestBody: {
|
|
94
|
+
content: { "application/json": { schema: requestSchema } },
|
|
95
|
+
required: true
|
|
96
|
+
},
|
|
97
|
+
responses: {
|
|
98
|
+
"200": {
|
|
99
|
+
content: {
|
|
100
|
+
"application/json": {
|
|
101
|
+
schema: { description: "RPC result. The shape is TS-inferred from the function's return type; best-effort — any JSON." }
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
description: "Successful RPC result (TypeScript-inferred return shape, documented best-effort)."
|
|
105
|
+
},
|
|
106
|
+
default: { $ref: ERROR_COMPONENT_REF }
|
|
107
|
+
},
|
|
108
|
+
summary: `${definition.kind}: ${functionPath}`,
|
|
109
|
+
tags: [tag],
|
|
110
|
+
"x-lunora-function-kind": definition.kind
|
|
111
|
+
};
|
|
112
|
+
return { operation, pathKey: `/_lunora/rpc#${functionPath}` };
|
|
113
|
+
};
|
|
114
|
+
const buildOpenApiDocument = (input) => {
|
|
115
|
+
const version = input.version ?? "0.0.0";
|
|
116
|
+
const paths = {};
|
|
117
|
+
const tagNames = /* @__PURE__ */ new Set();
|
|
118
|
+
for (const route of input.httpRoutes) {
|
|
119
|
+
const openApiPath = toOpenApiPath(route.path);
|
|
120
|
+
const pathItem = paths[openApiPath] ?? {};
|
|
121
|
+
pathItem[route.method.toLowerCase()] = httpRouteOperation(route);
|
|
122
|
+
paths[openApiPath] = pathItem;
|
|
123
|
+
tagNames.add(sanitizeNamespace(route.filePath));
|
|
124
|
+
}
|
|
125
|
+
const rpcFunctions = input.functions.filter((definition) => definition.visibility !== "internal" && definition.kind !== "stream");
|
|
126
|
+
for (const definition of rpcFunctions) {
|
|
127
|
+
const { operation, pathKey } = rpcOperation(definition);
|
|
128
|
+
paths[pathKey] = { post: operation };
|
|
129
|
+
tagNames.add(sanitizeNamespace(definition.filePath));
|
|
130
|
+
}
|
|
131
|
+
const tags = [...tagNames].toSorted((a, b) => a.localeCompare(b)).map((name) => {
|
|
132
|
+
return { description: `Operations declared in \`lunora/${name}\`.`, name };
|
|
133
|
+
});
|
|
134
|
+
const document = {
|
|
135
|
+
components: {
|
|
136
|
+
responses: {
|
|
137
|
+
LunoraError: {
|
|
138
|
+
content: {
|
|
139
|
+
"application/json": {
|
|
140
|
+
schema: {
|
|
141
|
+
additionalProperties: false,
|
|
142
|
+
description: "Standard Lunora error envelope.",
|
|
143
|
+
properties: {
|
|
144
|
+
error: {
|
|
145
|
+
additionalProperties: false,
|
|
146
|
+
properties: {
|
|
147
|
+
code: {
|
|
148
|
+
description: "Machine-readable error code. Clients switch on this value.",
|
|
149
|
+
enum: LUNORA_ERROR_CODES,
|
|
150
|
+
type: "string"
|
|
151
|
+
},
|
|
152
|
+
message: { description: "Human-readable error message (never echoes internal details).", type: "string" }
|
|
153
|
+
},
|
|
154
|
+
required: ["code", "message"],
|
|
155
|
+
type: "object"
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
required: ["error"],
|
|
159
|
+
type: "object"
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
description: "A Lunora error response. The HTTP status reflects the error code (e.g. BAD_REQUEST→400, UNAUTHORIZED→401, FORBIDDEN→403, NOT_FOUND→404)."
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
info: {
|
|
168
|
+
description: "Auto-generated from @lunora/values-typed functions by @lunora/codegen. Do not edit — run `lunora codegen` to regenerate.",
|
|
169
|
+
title: "Lunora API",
|
|
170
|
+
version
|
|
171
|
+
},
|
|
172
|
+
openapi: "3.1.0",
|
|
173
|
+
paths,
|
|
174
|
+
tags
|
|
175
|
+
};
|
|
176
|
+
return document;
|
|
177
|
+
};
|
|
178
|
+
const emitOpenApi = (input) => `${JSON.stringify(buildOpenApiDocument(input), void 0, 2)}
|
|
179
|
+
`;
|
|
180
|
+
const emitOpenApiModule = (document_) => `${GENERATED_HEADER}export const openApiSpec: Record<string, unknown> = ${JSON.stringify(document_, void 0, 4)};
|
|
181
|
+
`;
|
|
182
|
+
|
|
183
|
+
export { buildOpenApiDocument, emitOpenApi, emitOpenApiModule };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Node } from 'ts-morph';
|
|
2
|
+
|
|
3
|
+
const TS_EXTENSION_RE = /\.ts$/u;
|
|
4
|
+
const enclosingExportName = (call) => {
|
|
5
|
+
for (const ancestor of call.getAncestors()) {
|
|
6
|
+
if (Node.isVariableDeclaration(ancestor) && ancestor.getVariableStatement()?.hasExportKeyword() === true) {
|
|
7
|
+
return ancestor.getName();
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return "";
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export { TS_EXTENSION_RE as T, enclosingExportName as e };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { relative, sep } from 'node:path';
|
|
2
|
+
import { SyntaxKind, Node } from 'ts-morph';
|
|
3
|
+
import { T as TS_EXTENSION_RE, e as enclosingExportName } from './discover-ast-CT6BgBr4.mjs';
|
|
4
|
+
import { listLunoraSourceFiles } from './discoverFunctions-BWMczzBx.mjs';
|
|
5
|
+
|
|
6
|
+
const isAuthApiCall = (call) => {
|
|
7
|
+
const callee = call.getExpression();
|
|
8
|
+
if (!Node.isPropertyAccessExpression(callee)) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
const receiver = callee.getExpression();
|
|
12
|
+
if (Node.isPropertyAccessExpression(receiver)) {
|
|
13
|
+
return receiver.getName() === "authApi";
|
|
14
|
+
}
|
|
15
|
+
return Node.isIdentifier(receiver) && receiver.getText() === "authApi";
|
|
16
|
+
};
|
|
17
|
+
const hasHeadersProp = (call) => {
|
|
18
|
+
const argument = call.getArguments()[0];
|
|
19
|
+
if (argument === void 0) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
if (!Node.isObjectLiteralExpression(argument)) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
for (const property of argument.getProperties()) {
|
|
26
|
+
if ((Node.isPropertyAssignment(property) || Node.isShorthandPropertyAssignment(property)) && property.getName() === "headers") {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
if (Node.isSpreadAssignment(property)) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
};
|
|
35
|
+
const discoverAuthApiCalls = (project, lunoraDirectory) => {
|
|
36
|
+
const calls = [];
|
|
37
|
+
for (const filePath of listLunoraSourceFiles(lunoraDirectory)) {
|
|
38
|
+
const sourceFile = project.getSourceFile(filePath) ?? project.addSourceFileAtPath(filePath);
|
|
39
|
+
const relativePath = relative(lunoraDirectory, filePath).replace(TS_EXTENSION_RE, "").split(sep).join("/");
|
|
40
|
+
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
41
|
+
if (!isAuthApiCall(call)) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const exportName = enclosingExportName(call);
|
|
45
|
+
if (exportName === "") {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const callee = call.getExpression();
|
|
49
|
+
const method = Node.isPropertyAccessExpression(callee) ? callee.getName() : "";
|
|
50
|
+
calls.push({
|
|
51
|
+
exportName,
|
|
52
|
+
file: relativePath,
|
|
53
|
+
hasHeaders: hasHeadersProp(call),
|
|
54
|
+
line: call.getStartLineNumber(),
|
|
55
|
+
method
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return calls;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export { discoverAuthApiCalls as default };
|