@nanobpm/urban 0.37.0 → 0.39.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.
- package/dist/cli.js +6 -0
- package/dist/openapi/spec.d.ts +18 -2
- package/dist/openapi/spec.js +35 -12
- package/dist/runtime/core/modules/api.d.ts +10 -6
- package/dist/runtime/core/modules/api.js +62 -17
- package/dist/toolkit/artifact.d.ts +11 -0
- package/dist/toolkit/artifact.js +18 -0
- package/dist/toolkit/derivers/api.d.ts +9 -4
- package/dist/toolkit/derivers/api.js +60 -24
- package/dist/toolkit/gen.d.ts +2 -0
- package/dist/toolkit/gen.js +25 -3
- package/dist/toolkit/index.d.ts +1 -0
- package/package.json +1 -1
- package/src/cli.ts +6 -0
- package/src/openapi/spec.test.ts +44 -0
- package/src/openapi/spec.ts +49 -12
- package/src/runtime/core/modules/api.test.ts +151 -1
- package/src/runtime/core/modules/api.ts +66 -22
- package/src/toolkit/artifact.ts +19 -0
- package/src/toolkit/derivers/api.test.ts +160 -3
- package/src/toolkit/derivers/api.ts +65 -25
- package/src/toolkit/gen.test.ts +53 -0
- package/src/toolkit/gen.ts +29 -4
|
@@ -139,10 +139,167 @@ test("required query params aren't undefined-widened; optional ones use the ? mo
|
|
|
139
139
|
},
|
|
140
140
|
};
|
|
141
141
|
const out = emitApiBindings(qdoc);
|
|
142
|
-
assert.match(out, /"q": string
|
|
142
|
+
assert.match(out, /"q": string/); // required scalar string → present, coerced to its schema type
|
|
143
143
|
assert.doesNotMatch(out, /"q"\?:/); // not optional
|
|
144
|
-
assert.doesNotMatch(out, /"q": string \|
|
|
145
|
-
assert.match(out, /"page"\?: string
|
|
144
|
+
assert.doesNotMatch(out, /"q": string \| undefined/); // not undefined-widened
|
|
145
|
+
assert.match(out, /"page"\?: string/); // optional → ? modifier
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("params are typed by their schema (coerced), not the raw wire string", () => {
|
|
149
|
+
const pdoc: OpenApiDoc = {
|
|
150
|
+
openapi: "3.0.0",
|
|
151
|
+
paths: {
|
|
152
|
+
"/things/{id}": {
|
|
153
|
+
get: {
|
|
154
|
+
operationId: "getThing",
|
|
155
|
+
parameters: [
|
|
156
|
+
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
|
157
|
+
{ name: "verbose", in: "query", schema: { type: "boolean" } },
|
|
158
|
+
{ name: "tags", in: "query", schema: { type: "array", items: { type: "string" } } },
|
|
159
|
+
{ name: "raw", in: "query" }, // no schema → raw wire type
|
|
160
|
+
],
|
|
161
|
+
responses: { "200": {} },
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
const out = emitApiBindings(pdoc);
|
|
167
|
+
assert.match(out, /"id": number/); // integer path param → number, not string
|
|
168
|
+
assert.match(out, /"verbose"\?: boolean/); // boolean query → boolean
|
|
169
|
+
assert.match(out, /"tags"\?: Array<string>/); // array query → Array<item>
|
|
170
|
+
assert.match(out, /"raw"\?: string \| string\[\]/); // schemaless query → raw wire type
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("the response type unions every documented JSON response body (success + error)", () => {
|
|
174
|
+
const rdoc: OpenApiDoc = {
|
|
175
|
+
openapi: "3.0.0",
|
|
176
|
+
components: {
|
|
177
|
+
schemas: {
|
|
178
|
+
Ok: { type: "object", properties: { ok: { type: "boolean" } }, additionalProperties: false },
|
|
179
|
+
Err: { type: "object", properties: { error: { type: "string" } }, additionalProperties: false },
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
paths: {
|
|
183
|
+
"/do": {
|
|
184
|
+
post: {
|
|
185
|
+
operationId: "doIt",
|
|
186
|
+
responses: {
|
|
187
|
+
"200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Ok" } } } },
|
|
188
|
+
"400": { content: { "application/json": { schema: { $ref: "#/components/schemas/Err" } } } },
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
const out = emitApiBindings(rdoc);
|
|
195
|
+
assert.match(out, /export type DoItResponse = Ok \| Err;/); // both bodies in the union
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("an ejected operation keeps raw wire param/query types + an unknown body (runtime skips coercion)", () => {
|
|
199
|
+
const edoc: OpenApiDoc = {
|
|
200
|
+
openapi: "3.0.0",
|
|
201
|
+
paths: {
|
|
202
|
+
"/raw/{id}": {
|
|
203
|
+
post: {
|
|
204
|
+
operationId: "rawOp",
|
|
205
|
+
"x-urban-eject": true,
|
|
206
|
+
parameters: [
|
|
207
|
+
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
|
208
|
+
{ name: "n", in: "query", schema: { type: "integer" } },
|
|
209
|
+
],
|
|
210
|
+
requestBody: {
|
|
211
|
+
required: true,
|
|
212
|
+
content: { "application/json": { schema: { type: "object", properties: { a: { type: "string" } } } } },
|
|
213
|
+
},
|
|
214
|
+
responses: { "200": {} },
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
const out = emitApiBindings(edoc);
|
|
220
|
+
assert.match(out, /"id": string/); // ejected integer path param stays a raw wire string
|
|
221
|
+
assert.match(out, /"n"\?: string \| string\[\]/); // ejected query param stays raw wire
|
|
222
|
+
assert.match(out, /body: unknown;/); // ejected body is unvalidated → unknown
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("whole-surface eject (manifest api.eject) types every op as ejected (raw params, unknown body)", () => {
|
|
226
|
+
const sdoc: OpenApiDoc = {
|
|
227
|
+
openapi: "3.0.0",
|
|
228
|
+
paths: {
|
|
229
|
+
"/thing/{id}": {
|
|
230
|
+
post: {
|
|
231
|
+
operationId: "doThing",
|
|
232
|
+
parameters: [
|
|
233
|
+
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
|
234
|
+
{ name: "q", in: "query", required: true, schema: { type: "integer" } },
|
|
235
|
+
],
|
|
236
|
+
requestBody: {
|
|
237
|
+
required: true,
|
|
238
|
+
content: { "application/json": { schema: { type: "object", properties: { a: { type: "string" } } } } },
|
|
239
|
+
},
|
|
240
|
+
responses: { "200": {} },
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
// Without the surface flag the op is fully typed…
|
|
246
|
+
const typed = emitApiBindings(sdoc, false);
|
|
247
|
+
assert.match(typed, /"id": number/);
|
|
248
|
+
assert.match(typed, /"q": number/);
|
|
249
|
+
// …with whole-surface eject it degrades to raw wire + unknown body, matching the runtime.
|
|
250
|
+
const ejected = emitApiBindings(sdoc, true);
|
|
251
|
+
assert.match(ejected, /"id": string/); // raw wire path param
|
|
252
|
+
assert.match(ejected, /"q"\?: string \| string\[\]/); // required query param becomes optional + raw
|
|
253
|
+
assert.match(ejected, /body: unknown;/);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("an ejected required query param is typed optional (runtime does not enforce presence)", () => {
|
|
257
|
+
const edoc: OpenApiDoc = {
|
|
258
|
+
openapi: "3.0.0",
|
|
259
|
+
paths: {
|
|
260
|
+
"/raw": {
|
|
261
|
+
get: {
|
|
262
|
+
operationId: "rawReq",
|
|
263
|
+
"x-urban-eject": true,
|
|
264
|
+
parameters: [{ name: "req", in: "query", required: true, schema: { type: "integer" } }],
|
|
265
|
+
responses: { "200": {} },
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
const out = emitApiBindings(edoc);
|
|
271
|
+
assert.match(out, /"req"\?: string \| string\[\]/); // required-but-ejected → optional raw wire
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("a component or operationId colliding with a fixed generated name fails the drift gate", () => {
|
|
275
|
+
const cdoc: OpenApiDoc = {
|
|
276
|
+
openapi: "3.0.0",
|
|
277
|
+
components: { schemas: { ApiOperations: { type: "string" } } },
|
|
278
|
+
paths: { "/z": { get: { operationId: "getZ", responses: { "200": {} } } } },
|
|
279
|
+
};
|
|
280
|
+
assert.throws(() => emitApiBindings(cdoc), /duplicate TypeScript type "ApiOperations"/);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("a documented-but-bodyless status is excluded from the response union", () => {
|
|
284
|
+
const rdoc: OpenApiDoc = {
|
|
285
|
+
openapi: "3.1.0",
|
|
286
|
+
components: {
|
|
287
|
+
schemas: { Err: { type: "object", properties: { error: { type: "string" } }, additionalProperties: false } },
|
|
288
|
+
},
|
|
289
|
+
paths: {
|
|
290
|
+
"/x": {
|
|
291
|
+
get: {
|
|
292
|
+
operationId: "getX",
|
|
293
|
+
responses: {
|
|
294
|
+
"204": {}, // documented, no JSON body → not in the union
|
|
295
|
+
default: { content: { "application/json": { schema: { $ref: "#/components/schemas/Err" } } } },
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
const out = emitApiBindings(rdoc);
|
|
302
|
+
assert.match(out, /export type GetXResponse = Err;/); // only the schema'd default body
|
|
146
303
|
});
|
|
147
304
|
|
|
148
305
|
test("request body optionality follows requestBody.required (runtime passes undefined when absent)", () => {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Deriver: an OpenAPI document → `api-io.d.ts` (the endpoint type map) + `operations.ts` (the typed
|
|
2
2
|
// `defineOperation` wrapper). ADR 0058: the contract-first endpoint surface. This is the authoring
|
|
3
3
|
// half — per `operationId` it emits a `Request`/`Response` type from the operation's parameters,
|
|
4
|
-
// requestBody, and
|
|
4
|
+
// requestBody, and response (a union of every documented JSON response body), plus an
|
|
5
|
+
// `ApiOperationId` union — mirroring how
|
|
5
6
|
// `worker-io.d.ts` types `defineWorker` from the model (ADR 0033 §3). The runtime
|
|
6
7
|
// (`runtime/core/modules/api.ts`) is the matching half: it validates + routes from the SAME spec via
|
|
7
8
|
// the shared `openapi/spec.ts`, so types and runtime never drift.
|
|
@@ -117,23 +118,34 @@ function objectToTs(schema: OpenApiSchema, depth: number): string {
|
|
|
117
118
|
return "Record<string, never>";
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
/** The `params`/`query` object type for an operation, from its parameters
|
|
121
|
-
|
|
121
|
+
/** The `params`/`query` object type for an operation, from its parameters. Values are coerced to
|
|
122
|
+
* their declared schema type before the delegate receives them (the runtime coerces + validates,
|
|
123
|
+
* then forwards the coerced value), so a param's type is its schema type — not the raw wire string.
|
|
124
|
+
* A parameter with no schema, or ANY parameter of an ejected operation (whose runtime skips
|
|
125
|
+
* coercion/validation and hands the delegate the raw request), keeps the raw wire type (`string`,
|
|
126
|
+
* or `string | string[]` for query). Ejected query params are also made optional: the runtime does
|
|
127
|
+
* not enforce presence for ejected ops, so a "required" query param may in fact be absent. */
|
|
128
|
+
function paramsTs(op: OperationInfo, where: "path" | "query", ejected: boolean): string {
|
|
122
129
|
const ps = op.parameters.filter((p) => p.in === where);
|
|
123
130
|
if (ps.length === 0) return "{}";
|
|
131
|
+
const rawWire = where === "query" ? "string | string[]" : "string";
|
|
124
132
|
const fields = ps.map((p) => {
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
133
|
+
// Coerced to the schema type (number/boolean/array/enum/…); optionality is carried by the `?`
|
|
134
|
+
// modifier — don't fold `undefined` into the value type, or required params would still accept
|
|
135
|
+
// undefined. Schemaless params, and every param of an ejected op, fall back to the raw wire type.
|
|
136
|
+
// A required query param of an ejected op is nonetheless optional here (runtime skips presence);
|
|
137
|
+
// path params stay as declared (the route only matches when every path segment is present).
|
|
138
|
+
const opt = ejected && where === "query" ? "?" : p.required ? "" : "?";
|
|
139
|
+
const t = ejected || !p.schema ? rawWire : schemaToTs(p.schema);
|
|
130
140
|
return `${JSON.stringify(p.name)}${opt}: ${t}`;
|
|
131
141
|
});
|
|
132
142
|
return `{ ${fields.join("; ")} }`;
|
|
133
143
|
}
|
|
134
144
|
|
|
135
|
-
/** Emit `api-io.d.ts`: named component types + per-operation Request/Response + the id union.
|
|
136
|
-
|
|
145
|
+
/** Emit `api-io.d.ts`: named component types + per-operation Request/Response + the id union.
|
|
146
|
+
* `surfaceEject` reflects the manifest's whole-surface `api.eject` (which the deriver otherwise
|
|
147
|
+
* can't see): when set, EVERY operation is treated as ejected (raw params/query, `unknown` body). */
|
|
148
|
+
export function emitApiBindings(doc: OpenApiDoc, surfaceEject = false): string {
|
|
137
149
|
const ops = collectOperations(doc);
|
|
138
150
|
const components = doc.components?.schemas ?? {};
|
|
139
151
|
|
|
@@ -152,6 +164,11 @@ export function emitApiBindings(doc: OpenApiDoc): string {
|
|
|
152
164
|
}
|
|
153
165
|
emittedTypeNames.set(typeName, source);
|
|
154
166
|
};
|
|
167
|
+
// Reserve the fixed generated declarations (the operation-id union + operations map) up front, so
|
|
168
|
+
// a component or operationId whose stem collapses onto one of them fails the drift gate with a
|
|
169
|
+
// clear rename message instead of silently emitting a duplicate, uncompilable declaration.
|
|
170
|
+
claimTypeName("ApiOperationId", "the generated operation-id union");
|
|
171
|
+
claimTypeName("ApiOperations", "the generated operations map");
|
|
155
172
|
for (const name of Object.keys(components)) {
|
|
156
173
|
claimTypeName(typeStem(name), `component schema "${name}"`);
|
|
157
174
|
}
|
|
@@ -162,8 +179,9 @@ export function emitApiBindings(doc: OpenApiDoc): string {
|
|
|
162
179
|
|
|
163
180
|
const header =
|
|
164
181
|
"// AUTO-GENERATED by @nanobpm/urban from the app's OpenAPI spec (ADR 0058).\n" +
|
|
165
|
-
"// The endpoint type map: each operationId maps to its typed request (path/query params
|
|
166
|
-
"// JSON body) and
|
|
182
|
+
"// The endpoint type map: each operationId maps to its typed request (coerced path/query params\n" +
|
|
183
|
+
"// + JSON body) and response (a union of every documented JSON response body), derived from the\n" +
|
|
184
|
+
"// spec. Do not edit — regenerated from\n" +
|
|
167
185
|
"// the spec; erased to plain JS at compile.\n" +
|
|
168
186
|
"// eslint-disable\n\n";
|
|
169
187
|
|
|
@@ -175,22 +193,38 @@ export function emitApiBindings(doc: OpenApiDoc): string {
|
|
|
175
193
|
const opDecls = ops
|
|
176
194
|
.map((op) => {
|
|
177
195
|
const stem = typeStem(op.operationId);
|
|
196
|
+
const ejected = surfaceEject || op.eject;
|
|
197
|
+
// An ejected operation (`x-urban-eject`, or the whole surface via manifest `api.eject`)
|
|
198
|
+
// bypasses the generated coercion/validation — its delegate reads the raw request — so its
|
|
199
|
+
// input is NOT schema-shaped at runtime: params/query stay raw wire types and the body is
|
|
200
|
+
// unvalidated (`unknown`). Typing it by the schema would claim guarantees the runtime doesn't
|
|
201
|
+
// make. (ADR 0059 documents the ejection exception.)
|
|
178
202
|
// With a schema → the typed body. Required but no JSON schema → `unknown` (a body IS
|
|
179
203
|
// required at runtime, just of an unknown shape). Otherwise (optional, absent) → `undefined`.
|
|
180
|
-
const bodyType =
|
|
181
|
-
?
|
|
182
|
-
: op.
|
|
183
|
-
?
|
|
184
|
-
:
|
|
204
|
+
const bodyType = ejected
|
|
205
|
+
? "unknown"
|
|
206
|
+
: op.requestBodySchema
|
|
207
|
+
? schemaToTs(op.requestBodySchema)
|
|
208
|
+
: op.requestBodyRequired
|
|
209
|
+
? "unknown"
|
|
210
|
+
: "undefined";
|
|
185
211
|
// An optional (or absent) request body is passed to the delegate as `undefined` at runtime,
|
|
186
212
|
// so mark it optional in the type rather than forcing handlers to treat it as always-present.
|
|
187
213
|
const bodyOpt = op.requestBodyRequired ? "" : "?";
|
|
188
|
-
|
|
214
|
+
// Response type: a union of every documented JSON response body (success + errors), so a
|
|
215
|
+
// handler that returns a documented error body typechecks against its operation's response
|
|
216
|
+
// type. Bodyless documented statuses carry no schema and are skipped here. Deduped (identical
|
|
217
|
+
// schemas collapse), stable order. No documented JSON body → unknown.
|
|
218
|
+
const respSeen = new Set<string>();
|
|
219
|
+
const respParts = op.responseSchemas
|
|
220
|
+
.flatMap((e) => (e.schema ? [schemaToTs(e.schema)] : []))
|
|
221
|
+
.filter((t) => (respSeen.has(t) ? false : (respSeen.add(t), true)));
|
|
222
|
+
const resp = respParts.length > 0 ? respParts.join(" | ") : "unknown";
|
|
189
223
|
const summary = op.summary ? `/** ${op.summary.replace(/\*\//g, "*\\/")} */\n` : "";
|
|
190
224
|
return (
|
|
191
225
|
`${summary}export interface ${stem}Request {\n` +
|
|
192
|
-
` params: ${paramsTs(op, "path")};\n` +
|
|
193
|
-
` query: ${paramsTs(op, "query")};\n` +
|
|
226
|
+
` params: ${paramsTs(op, "path", ejected)};\n` +
|
|
227
|
+
` query: ${paramsTs(op, "query", ejected)};\n` +
|
|
194
228
|
` body${bodyOpt}: ${bodyType};\n` +
|
|
195
229
|
`}\n` +
|
|
196
230
|
`export type ${stem}Response = ${resp};`
|
|
@@ -308,18 +342,24 @@ export function emitApiController(doc: OpenApiDoc, operationsDir: string = DEFAU
|
|
|
308
342
|
}
|
|
309
343
|
|
|
310
344
|
/** Derive the api artifacts from a parsed OpenAPI document. `operationsDir` is the manifest's
|
|
311
|
-
* `api.dir` (default `operations/`) — the controller's static delegate imports resolve into it.
|
|
312
|
-
|
|
345
|
+
* `api.dir` (default `operations/`) — the controller's static delegate imports resolve into it.
|
|
346
|
+
* `surfaceEject` is the manifest's whole-surface `api.eject`: when set, every operation is typed as
|
|
347
|
+
* ejected (raw params/query, `unknown` body) so the generated types match the runtime. */
|
|
348
|
+
export function deriveApi(
|
|
349
|
+
doc: OpenApiDoc,
|
|
350
|
+
operationsDir: string = DEFAULT_OPERATIONS_DIR,
|
|
351
|
+
surfaceEject = false,
|
|
352
|
+
): DerivedArtifact[] {
|
|
313
353
|
return [
|
|
314
|
-
{ path: `${GENERATED_DIR}/${API_BINDINGS_DTS}`, content: emitApiBindings(doc) },
|
|
354
|
+
{ path: `${GENERATED_DIR}/${API_BINDINGS_DTS}`, content: emitApiBindings(doc, surfaceEject) },
|
|
315
355
|
{ path: `${GENERATED_DIR}/${API_BINDINGS_TS}`, content: emitApiBindingsRuntime() },
|
|
316
356
|
{ path: `${GENERATED_DIR}/${API_CONTROLLER_TS}`, content: emitApiController(doc, operationsDir) },
|
|
317
357
|
];
|
|
318
358
|
}
|
|
319
359
|
|
|
320
|
-
export const apiDeriver: Deriver<{ doc: OpenApiDoc; operationsDir?: string }> = {
|
|
360
|
+
export const apiDeriver: Deriver<{ doc: OpenApiDoc; operationsDir?: string; surfaceEject?: boolean }> = {
|
|
321
361
|
id: "openapi->api-io",
|
|
322
362
|
describe:
|
|
323
363
|
"Derive api-io.d.ts (typed request/response per operationId) + the typed defineOperation + the delegate registry.",
|
|
324
|
-
derive: ({ doc, operationsDir }) => deriveApi(doc, operationsDir),
|
|
364
|
+
derive: ({ doc, operationsDir, surfaceEject }) => deriveApi(doc, operationsDir, surfaceEject),
|
|
325
365
|
};
|
package/src/toolkit/gen.test.ts
CHANGED
|
@@ -103,6 +103,59 @@ test("collectArtifacts touches no writes", async () => {
|
|
|
103
103
|
assert.equal(Object.keys(io.files).length, before);
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
test("runGen sweeps stale files from nano-generated/ (write mode)", async () => {
|
|
107
|
+
const io = memIO(fixture());
|
|
108
|
+
// A stale artifact left by a prior gen (e.g. a renamed `data_sdk.ts` orphan) that this run does
|
|
109
|
+
// not re-emit. It must be removed so the app can't import a dead generated module.
|
|
110
|
+
io.files["/app/nano-generated/data_sdk.ts"] = "// stale, renamed away";
|
|
111
|
+
io.files["/app/nano-generated/old-worker-io.d.ts"] = "// stale";
|
|
112
|
+
const res = await runGen({ root: "/app", io });
|
|
113
|
+
assert.deepEqual(res.swept, [
|
|
114
|
+
"nano-generated/data_sdk.ts",
|
|
115
|
+
"nano-generated/old-worker-io.d.ts",
|
|
116
|
+
]);
|
|
117
|
+
assert.ok(!("/app/nano-generated/data_sdk.ts" in io.files), "stale file removed");
|
|
118
|
+
assert.ok(!("/app/nano-generated/old-worker-io.d.ts" in io.files), "stale file removed");
|
|
119
|
+
// Current artifacts survive.
|
|
120
|
+
assert.ok("/app/nano-generated/worker-io.d.ts" in io.files, "fresh artifact kept");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("runGen never sweeps runtime-materialized wrappers gen does not emit", async () => {
|
|
124
|
+
const io = memIO(fixture());
|
|
125
|
+
// The SDK shims + `urban data`/console dataops wrappers live in nano-generated/ but are not
|
|
126
|
+
// produced by `urban gen`; the app needs them at runtime, so the sweep must protect them.
|
|
127
|
+
for (const name of ["domain.ts", "workers.ts", "messages.ts", "domain.json", "data-sdk.ts", "worker-sdk.ts"]) {
|
|
128
|
+
io.files[`/app/nano-generated/${name}`] = `// runtime-materialized ${name}`;
|
|
129
|
+
}
|
|
130
|
+
const res = await runGen({ root: "/app", io });
|
|
131
|
+
assert.deepEqual(res.swept, [], "no runtime-materialized file swept");
|
|
132
|
+
for (const name of ["domain.ts", "workers.ts", "messages.ts", "domain.json", "data-sdk.ts", "worker-sdk.ts"]) {
|
|
133
|
+
assert.ok(`/app/nano-generated/${name}` in io.files, `${name} preserved`);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("gen --check never sweeps (read-only)", async () => {
|
|
138
|
+
const io = memIO(fixture());
|
|
139
|
+
await runGen({ root: "/app", io });
|
|
140
|
+
io.files["/app/nano-generated/stale.ts"] = "// stale";
|
|
141
|
+
const res = await runGen({ root: "/app", io, check: true });
|
|
142
|
+
assert.deepEqual(res.swept, []);
|
|
143
|
+
assert.ok("/app/nano-generated/stale.ts" in io.files, "check mode leaves stale files untouched");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("modelsOnly (urban derive) never sweeps nano-generated type-contract outputs", async () => {
|
|
147
|
+
const io = memIO(fixture());
|
|
148
|
+
// A prior `urban gen` wrote the type-contract outputs.
|
|
149
|
+
await runGen({ root: "/app", io });
|
|
150
|
+
assert.ok("/app/nano-generated/worker-io.d.ts" in io.files, "precondition: gen wrote outputs");
|
|
151
|
+
// `urban derive` (modelsOnly) derives only models — its artifacts carry no nano-generated/*, so
|
|
152
|
+
// the sweep must be a no-op here; otherwise it would wipe the type contracts above.
|
|
153
|
+
const res = await runGen({ root: "/app", io, modelsOnly: true });
|
|
154
|
+
assert.deepEqual(res.swept, []);
|
|
155
|
+
assert.ok("/app/nano-generated/worker-io.d.ts" in io.files, "derive preserved the type contracts");
|
|
156
|
+
assert.ok("/app/nano-generated/app.schema.sql" in io.files, "derive preserved the migrations");
|
|
157
|
+
});
|
|
158
|
+
|
|
106
159
|
// --- code-first model derivation (urban gen / urban derive) ---
|
|
107
160
|
|
|
108
161
|
const GreetIn = envelope("GreetIn", { who: "string" });
|
package/src/toolkit/gen.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// pure; all IO is confined here behind a tiny FS port so the same code runs on Node and Deno.
|
|
5
5
|
|
|
6
6
|
import type { DerivedArtifact } from "./artifact.ts";
|
|
7
|
-
import { isAbsolutePath, sortArtifacts } from "./artifact.ts";
|
|
7
|
+
import { GENERATED_DIR, isAbsolutePath, RUNTIME_MATERIALIZED_ARTIFACTS, sortArtifacts } from "./artifact.ts";
|
|
8
8
|
import { deriveMigrations, type ToolkitManifest } from "./derivers/migrations.ts";
|
|
9
9
|
import { deriveDomain } from "./derivers/domain.ts";
|
|
10
10
|
import { deriveWorkerBindings, type ModelSource } from "./derivers/worker-io.ts";
|
|
@@ -51,6 +51,8 @@ export interface GenResult {
|
|
|
51
51
|
artifacts: DerivedArtifact[];
|
|
52
52
|
/** Paths that differ from disk (only populated by `check`). */
|
|
53
53
|
drift: string[];
|
|
54
|
+
/** App-relative paths of stale files removed from `nano-generated/` (only populated in write mode). */
|
|
55
|
+
swept: string[];
|
|
54
56
|
/** True if any code-first workflow failed to import/derive. */
|
|
55
57
|
incomplete: boolean;
|
|
56
58
|
/** Per-file model-derivation errors (empty on success). */
|
|
@@ -122,7 +124,7 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
|
|
|
122
124
|
const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
|
|
123
125
|
const manifest: ToolkitManifest & {
|
|
124
126
|
models?: { processes?: string[]; workflows?: string[] };
|
|
125
|
-
api?: { spec?: string; dir?: string };
|
|
127
|
+
api?: { spec?: string; dir?: string; eject?: boolean };
|
|
126
128
|
} = JSON.parse(await io.readText(manifestPath));
|
|
127
129
|
|
|
128
130
|
const artifacts: DerivedArtifact[] = [];
|
|
@@ -162,7 +164,7 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
|
|
|
162
164
|
const opsDir = typeof manifest.api?.dir === "string" && manifest.api.dir.trim().length > 0
|
|
163
165
|
? manifest.api.dir.trim()
|
|
164
166
|
: undefined;
|
|
165
|
-
artifacts.push(...deriveApi(parseSpec(specText), opsDir));
|
|
167
|
+
artifacts.push(...deriveApi(parseSpec(specText), opsDir, manifest.api?.eject === true));
|
|
166
168
|
}
|
|
167
169
|
|
|
168
170
|
return { artifacts: sortArtifacts(artifacts), derived };
|
|
@@ -199,7 +201,30 @@ export async function runGen(opts: GenOptions & { check?: boolean }): Promise<Ge
|
|
|
199
201
|
}
|
|
200
202
|
}
|
|
201
203
|
|
|
202
|
-
|
|
204
|
+
// Stale-artifact sweep of `nano-generated/` (clean break, nano-bpm#: gen owns its output dir).
|
|
205
|
+
// The dir is gitignored and fully codegen-owned, so any file this run did not (re)write is stale
|
|
206
|
+
// from a prior gen — a renamed or removed artifact (e.g. an old `data_sdk.ts`) whose orphan the
|
|
207
|
+
// app might still import. Remove it. The one exception is the runtime-materialized set: the SDK
|
|
208
|
+
// shims + the `urban data`/console dataops wrappers gen does NOT emit but the app needs at runtime
|
|
209
|
+
// (RUNTIME_MATERIALIZED_ARTIFACTS). Protect those by name. Write mode only; skipped when io.remove
|
|
210
|
+
// is absent (the sweep is best-effort, mirroring the `.bpmn` sweep above). NOT on `modelsOnly`
|
|
211
|
+
// (`urban derive`): that path derives only models, so `artifacts` carries no `nano-generated/*` and
|
|
212
|
+
// sweeping would wrongly wipe the type-contract outputs a prior `urban gen` wrote.
|
|
213
|
+
const swept: string[] = [];
|
|
214
|
+
if (!opts.check && !opts.modelsOnly && !derived.incomplete && io.remove) {
|
|
215
|
+
const keep = new Set(
|
|
216
|
+
artifacts.map((a) => a.path).filter((p) => p.startsWith(`${GENERATED_DIR}/`)),
|
|
217
|
+
);
|
|
218
|
+
for (const name of RUNTIME_MATERIALIZED_ARTIFACTS) keep.add(`${GENERATED_DIR}/${name}`);
|
|
219
|
+
for (const name of await io.listDir(join(root, GENERATED_DIR))) {
|
|
220
|
+
const rel = `${GENERATED_DIR}/${name}`;
|
|
221
|
+
if (keep.has(rel)) continue;
|
|
222
|
+
await io.remove(join(root, rel));
|
|
223
|
+
swept.push(rel);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors };
|
|
203
228
|
}
|
|
204
229
|
|
|
205
230
|
export { dirOf, join as joinPath };
|