@nanobpm/urban 0.28.0 → 0.30.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/openapi/spec.d.ts +127 -0
- package/dist/openapi/spec.js +444 -0
- package/dist/runtime/adapters/deno.js +5 -0
- package/dist/runtime/adapters/node.js +5 -0
- package/dist/runtime/core/execContext.d.ts +28 -0
- package/dist/runtime/core/execContext.js +39 -0
- package/dist/runtime/core/host.d.ts +41 -0
- package/dist/runtime/core/manifest.d.ts +1 -1
- package/dist/runtime/core/modules/api.d.ts +61 -0
- package/dist/runtime/core/modules/api.js +359 -0
- package/dist/runtime/core/modules/cancel.d.ts +31 -0
- package/dist/runtime/core/modules/cancel.js +90 -0
- package/dist/runtime/core/modules/connectors.js +3 -1
- package/dist/runtime/core/modules/datasource.d.ts +23 -3
- package/dist/runtime/core/modules/datasource.js +61 -6
- package/dist/runtime/core/modules/gateway.d.ts +10 -3
- package/dist/runtime/core/modules/gateway.js +23 -6
- package/dist/runtime/core/modules/instance-tracking.d.ts +22 -0
- package/dist/runtime/core/modules/instance-tracking.js +158 -0
- package/dist/runtime/core/modules/pages.d.ts +8 -0
- package/dist/runtime/core/modules/pages.js +17 -1
- package/dist/runtime/core/modules/scheduler.d.ts +11 -0
- package/dist/runtime/core/modules/scheduler.js +21 -0
- package/dist/runtime/core/modules/surfaces.js +9 -0
- package/dist/runtime/core/modules/triggers.d.ts +2 -8
- package/dist/runtime/core/modules/triggers.js +1 -16
- package/dist/runtime/core/modules/workers.js +3 -2
- package/dist/runtime/core/runtime.d.ts +3 -0
- package/dist/runtime/core/runtime.js +12 -0
- package/dist/runtime/core/validate.js +38 -0
- package/dist/runtime/engine/nanosdk.d.ts +14 -1
- package/dist/runtime/engine/nanosdk.js +90 -7
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +2 -0
- package/dist/toolkit/artifact.d.ts +5 -0
- package/dist/toolkit/artifact.js +7 -0
- package/dist/toolkit/derivers/api.d.ts +19 -0
- package/dist/toolkit/derivers/api.js +224 -0
- package/dist/toolkit/gen.js +23 -4
- package/dist/toolkit/index.d.ts +5 -0
- package/dist/toolkit/index.js +12 -1
- package/package.json +2 -2
- package/src/openapi/spec.test.ts +292 -0
- package/src/openapi/spec.ts +540 -0
- package/src/runtime/adapters/deno.ts +6 -0
- package/src/runtime/adapters/node.ts +6 -0
- package/src/runtime/core/execContext.ts +56 -0
- package/src/runtime/core/host.ts +44 -0
- package/src/runtime/core/manifest.ts +1 -0
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/api.test.ts +445 -0
- package/src/runtime/core/modules/api.ts +453 -0
- package/src/runtime/core/modules/cancel.test.ts +310 -0
- package/src/runtime/core/modules/cancel.ts +119 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/connectors.ts +7 -1
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/datasource.ts +77 -6
- package/src/runtime/core/modules/gateway.ts +28 -6
- package/src/runtime/core/modules/instance-tracking.test.ts +345 -0
- package/src/runtime/core/modules/instance-tracking.ts +180 -0
- package/src/runtime/core/modules/pages.test.ts +47 -2
- package/src/runtime/core/modules/pages.ts +23 -1
- package/src/runtime/core/modules/provenance.test.ts +181 -0
- package/src/runtime/core/modules/scheduler.ts +31 -0
- package/src/runtime/core/modules/surfaces.test.ts +2 -0
- package/src/runtime/core/modules/surfaces.ts +10 -0
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/triggers.ts +3 -25
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/modules/workers.ts +11 -2
- package/src/runtime/core/runtime.test.ts +3 -0
- package/src/runtime/core/runtime.ts +16 -0
- package/src/runtime/core/validate.test.ts +96 -0
- package/src/runtime/core/validate.ts +39 -0
- package/src/runtime/engine/nanosdk.test.ts +118 -5
- package/src/runtime/engine/nanosdk.ts +102 -7
- package/src/runtime/index.ts +12 -0
- package/src/toolkit/artifact.ts +8 -0
- package/src/toolkit/derivers/api.test.ts +219 -0
- package/src/toolkit/derivers/api.ts +255 -0
- package/src/toolkit/gen.test.ts +39 -0
- package/src/toolkit/gen.ts +24 -4
- package/src/toolkit/index.ts +41 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/** A JSON Schema (the OpenAPI subset we read). Kept structural and permissive — unknown keywords
|
|
2
|
+
* are ignored by the validator and fall back to `unknown` in the type emitter. */
|
|
3
|
+
export interface OpenApiSchema {
|
|
4
|
+
$ref?: string;
|
|
5
|
+
type?: string;
|
|
6
|
+
nullable?: boolean;
|
|
7
|
+
enum?: unknown[];
|
|
8
|
+
const?: unknown;
|
|
9
|
+
format?: string;
|
|
10
|
+
properties?: Record<string, OpenApiSchema>;
|
|
11
|
+
required?: string[];
|
|
12
|
+
additionalProperties?: boolean | OpenApiSchema;
|
|
13
|
+
items?: OpenApiSchema;
|
|
14
|
+
minItems?: number;
|
|
15
|
+
maxItems?: number;
|
|
16
|
+
minLength?: number;
|
|
17
|
+
maxLength?: number;
|
|
18
|
+
pattern?: string;
|
|
19
|
+
minimum?: number;
|
|
20
|
+
maximum?: number;
|
|
21
|
+
exclusiveMinimum?: number | boolean;
|
|
22
|
+
exclusiveMaximum?: number | boolean;
|
|
23
|
+
allOf?: OpenApiSchema[];
|
|
24
|
+
oneOf?: OpenApiSchema[];
|
|
25
|
+
anyOf?: OpenApiSchema[];
|
|
26
|
+
description?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Whether a schema describes an object shape. OpenAPI/JSON-Schema let the `type`
|
|
30
|
+
* keyword be omitted when `properties`/`required`/`additionalProperties` already imply
|
|
31
|
+
* an object. The type emitter (`schemaToTs`) and the runtime validator (`validateValue`)
|
|
32
|
+
* both route object detection through this single predicate so they never drift — a body
|
|
33
|
+
* the emitted type calls an object is the same body the validator shape-checks.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isObjectSchema(schema: OpenApiSchema): boolean;
|
|
36
|
+
export interface OpenApiParameter {
|
|
37
|
+
name: string;
|
|
38
|
+
in: "path" | "query" | "header" | "cookie";
|
|
39
|
+
required?: boolean;
|
|
40
|
+
schema?: OpenApiSchema;
|
|
41
|
+
}
|
|
42
|
+
export interface OpenApiMediaType {
|
|
43
|
+
schema?: OpenApiSchema;
|
|
44
|
+
}
|
|
45
|
+
export interface OpenApiRequestBody {
|
|
46
|
+
required?: boolean;
|
|
47
|
+
content?: Record<string, OpenApiMediaType>;
|
|
48
|
+
}
|
|
49
|
+
export interface OpenApiResponse {
|
|
50
|
+
description?: string;
|
|
51
|
+
content?: Record<string, OpenApiMediaType>;
|
|
52
|
+
}
|
|
53
|
+
export interface OpenApiDoc {
|
|
54
|
+
openapi?: string;
|
|
55
|
+
info?: {
|
|
56
|
+
title?: string;
|
|
57
|
+
version?: string;
|
|
58
|
+
};
|
|
59
|
+
paths?: Record<string, Record<string, unknown>>;
|
|
60
|
+
components?: {
|
|
61
|
+
schemas?: Record<string, OpenApiSchema>;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** The HTTP methods we mount, lowercase as they appear as path-item keys. */
|
|
65
|
+
export declare const HTTP_METHODS: readonly ["get", "put", "post", "delete", "patch", "options", "head"];
|
|
66
|
+
export type HttpMethodLower = (typeof HTTP_METHODS)[number];
|
|
67
|
+
/** One resolved operation: its id, method, path, parameters, request body schema, and responses.
|
|
68
|
+
* This is the shared unit both the deriver (→ types) and the runtime (→ routes) consume. */
|
|
69
|
+
export interface OperationInfo {
|
|
70
|
+
operationId: string;
|
|
71
|
+
method: HttpMethodLower;
|
|
72
|
+
/** The OpenAPI path template, e.g. "/invoices/{id}". */
|
|
73
|
+
path: string;
|
|
74
|
+
parameters: OpenApiParameter[];
|
|
75
|
+
requestBodySchema?: OpenApiSchema;
|
|
76
|
+
requestBodyRequired: boolean;
|
|
77
|
+
/** Schema of the first 2xx (else default) JSON response, when declared. */
|
|
78
|
+
responseSchema?: OpenApiSchema;
|
|
79
|
+
eject: boolean;
|
|
80
|
+
summary?: string;
|
|
81
|
+
}
|
|
82
|
+
/** Parse an OpenAPI document from text. JSON only in this slice (ADR 0058 open question: YAML).
|
|
83
|
+
* Throws with a clear message on malformed input. */
|
|
84
|
+
export declare function parseSpec(text: string): OpenApiDoc;
|
|
85
|
+
/** The basename of a local `#/components/schemas/Name` ref, else undefined. */
|
|
86
|
+
export declare function refName(ref: string): string | undefined;
|
|
87
|
+
/** Follow local component `$ref`s to the concrete schema (guarding cycles). Non-local refs → undefined. */
|
|
88
|
+
export declare function resolveSchema(doc: OpenApiDoc, schema: OpenApiSchema | undefined): OpenApiSchema | undefined;
|
|
89
|
+
/** Walk the document's paths and return every operation that carries an operationId, in a stable
|
|
90
|
+
* (path, method) order. Operations without an operationId are skipped here; the runtime mount logs
|
|
91
|
+
* them at `warn` and skips them too — this keeps the pure collector total. */
|
|
92
|
+
export declare function collectOperations(doc: OpenApiDoc): OperationInfo[];
|
|
93
|
+
/** "method path" pointers for every operation that declares no `operationId`. Such operations are
|
|
94
|
+
* unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
|
|
95
|
+
export declare function operationsWithoutId(doc: OpenApiDoc): string[];
|
|
96
|
+
/** "METHOD path {param}" pointers for every path-template parameter that no declared `in: "path"`
|
|
97
|
+
* parameter covers. Such params are still captured and passed to the delegate at runtime, but are
|
|
98
|
+
* neither typed (the deriver only emits declared params) nor validated — a type/runtime drift the
|
|
99
|
+
* runtime mount surfaces at `warn`. */
|
|
100
|
+
export declare function undeclaredPathParams(doc: OpenApiDoc): string[];
|
|
101
|
+
/** An operationId names the delegate module file (`<dir>/<operationId>`), so it must be a single
|
|
102
|
+
* safe path segment. Rejects separators and parent-dir traversal so a crafted spec can't import a
|
|
103
|
+
* file outside the operations directory. Single source of truth for the rule (used by the pure
|
|
104
|
+
* collector, the diagnostic collector, and the runtime import sink). */
|
|
105
|
+
export declare function isSafeOperationId(id: string): boolean;
|
|
106
|
+
/** "METHOD path (operationId)" pointers for operations whose operationId is present but not a safe
|
|
107
|
+
* path segment. collectOperations skips these (they never mount or drive an import); the runtime
|
|
108
|
+
* surfaces them at `warn`. */
|
|
109
|
+
export declare function operationsWithUnsafeId(doc: OpenApiDoc): string[];
|
|
110
|
+
/** Turn an OpenAPI path template ("/invoices/{id}") into a matcher under `base` ("/app/api"):
|
|
111
|
+
* a RegExp that captures each `{param}` positionally, plus the ordered path-parameter names. The
|
|
112
|
+
* runtime matches concrete request paths, so a templated segment becomes a single-segment capture. */
|
|
113
|
+
/** Escape RegExp metacharacters so a string is matched literally inside a `new RegExp(...)`. */
|
|
114
|
+
export declare function escapeRegex(s: string): string;
|
|
115
|
+
export declare function toRouteMatcher(base: string, path: string): {
|
|
116
|
+
pattern: RegExp;
|
|
117
|
+
paramNames: string[];
|
|
118
|
+
sample: string;
|
|
119
|
+
};
|
|
120
|
+
/** One validation failure: a JSON-pointer-ish path and a human message. */
|
|
121
|
+
export interface ValidationIssue {
|
|
122
|
+
path: string;
|
|
123
|
+
message: string;
|
|
124
|
+
}
|
|
125
|
+
/** Validate `value` against `schema` (resolving local $refs against `doc`), returning every issue
|
|
126
|
+
* found (empty = valid). A pragmatic JSON-Schema subset; unknown keywords are ignored. */
|
|
127
|
+
export declare function validateValue(doc: OpenApiDoc, schemaIn: OpenApiSchema | undefined, value: unknown, path?: string): ValidationIssue[];
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// Shared, pure OpenAPI machinery for the Urban endpoint surface (ADR 0058). Both the toolkit
|
|
2
|
+
// deriver (`toolkit/derivers/api.ts`, authoring-time type + wrapper emission) and the runtime
|
|
3
|
+
// (`runtime/core/modules/api.ts`, request validation + routing) build on this one module, so the
|
|
4
|
+
// derived types and the runtime routes/validation always agree. No IO, no `node:*`, no `Deno` —
|
|
5
|
+
// it operates on an already-parsed document object.
|
|
6
|
+
//
|
|
7
|
+
// Scope: the ADR 0058 "supported profile" — JSON bodies, path/query parameters, a JSON
|
|
8
|
+
// requestBody, response codes, and a JSON-Schema subset validator (type/required/enum/numeric &
|
|
9
|
+
// string bounds/pattern/array & object shape/nullable/$ref). Exotic OpenAPI (callbacks, links,
|
|
10
|
+
// XML, discriminated oneOf composition) is intentionally out of scope for this slice.
|
|
11
|
+
/**
|
|
12
|
+
* Whether a schema describes an object shape. OpenAPI/JSON-Schema let the `type`
|
|
13
|
+
* keyword be omitted when `properties`/`required`/`additionalProperties` already imply
|
|
14
|
+
* an object. The type emitter (`schemaToTs`) and the runtime validator (`validateValue`)
|
|
15
|
+
* both route object detection through this single predicate so they never drift — a body
|
|
16
|
+
* the emitted type calls an object is the same body the validator shape-checks.
|
|
17
|
+
*/
|
|
18
|
+
export function isObjectSchema(schema) {
|
|
19
|
+
if (schema.type === "object")
|
|
20
|
+
return true;
|
|
21
|
+
if (schema.type !== undefined)
|
|
22
|
+
return false;
|
|
23
|
+
return (schema.properties !== undefined ||
|
|
24
|
+
schema.required !== undefined ||
|
|
25
|
+
schema.additionalProperties !== undefined);
|
|
26
|
+
}
|
|
27
|
+
/** The HTTP methods we mount, lowercase as they appear as path-item keys. */
|
|
28
|
+
export const HTTP_METHODS = ["get", "put", "post", "delete", "patch", "options", "head"];
|
|
29
|
+
function isRecord(v) {
|
|
30
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
31
|
+
}
|
|
32
|
+
/** A structural gate for a schema object: any record is treated as a (loose) OpenAPI schema. */
|
|
33
|
+
function isSchema(v) {
|
|
34
|
+
return isRecord(v);
|
|
35
|
+
}
|
|
36
|
+
/** Parse an OpenAPI document from text. JSON only in this slice (ADR 0058 open question: YAML).
|
|
37
|
+
* Throws with a clear message on malformed input. */
|
|
38
|
+
export function parseSpec(text) {
|
|
39
|
+
let doc;
|
|
40
|
+
try {
|
|
41
|
+
doc = JSON.parse(text);
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
throw new Error(`OpenAPI spec is not valid JSON (YAML is not yet supported — ADR 0058): ${e instanceof Error ? e.message : String(e)}`);
|
|
45
|
+
}
|
|
46
|
+
if (!isRecord(doc))
|
|
47
|
+
throw new Error("OpenAPI spec must be a JSON object");
|
|
48
|
+
return doc;
|
|
49
|
+
}
|
|
50
|
+
/** The basename of a local `#/components/schemas/Name` ref, else undefined. */
|
|
51
|
+
export function refName(ref) {
|
|
52
|
+
const m = ref.match(/^#\/components\/schemas\/([^/]+)$/);
|
|
53
|
+
return m ? m[1] : undefined;
|
|
54
|
+
}
|
|
55
|
+
/** Follow local component `$ref`s to the concrete schema (guarding cycles). Non-local refs → undefined. */
|
|
56
|
+
export function resolveSchema(doc, schema) {
|
|
57
|
+
if (!schema)
|
|
58
|
+
return undefined;
|
|
59
|
+
let current = schema;
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
while (current.$ref) {
|
|
62
|
+
if (seen.has(current.$ref))
|
|
63
|
+
return undefined; // cyclic ref guard
|
|
64
|
+
seen.add(current.$ref);
|
|
65
|
+
const name = refName(current.$ref);
|
|
66
|
+
const next = name ? doc.components?.schemas?.[name] : undefined;
|
|
67
|
+
if (!next)
|
|
68
|
+
return undefined;
|
|
69
|
+
current = next;
|
|
70
|
+
}
|
|
71
|
+
return current;
|
|
72
|
+
}
|
|
73
|
+
function firstJsonSchema(content) {
|
|
74
|
+
if (!content)
|
|
75
|
+
return undefined;
|
|
76
|
+
const pick = (media) => isRecord(media) && isSchema(media.schema) ? media.schema : undefined;
|
|
77
|
+
// JSON-only: the runtime always JSON.parses the body and responds application/json, so derive
|
|
78
|
+
// types/validation only from JSON media types (application/json, its charset variants, and any
|
|
79
|
+
// `+json` structured suffix). A non-JSON media type is ignored rather than typed as if JSON.
|
|
80
|
+
for (const [type, media] of Object.entries(content)) {
|
|
81
|
+
const base = type.split(";", 1)[0].trim().toLowerCase();
|
|
82
|
+
if (base === "application/json" || base.endsWith("+json")) {
|
|
83
|
+
const s = pick(media);
|
|
84
|
+
if (s)
|
|
85
|
+
return s;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
/** Walk the document's paths and return every operation that carries an operationId, in a stable
|
|
91
|
+
* (path, method) order. Operations without an operationId are skipped here; the runtime mount logs
|
|
92
|
+
* them at `warn` and skips them too — this keeps the pure collector total. */
|
|
93
|
+
export function collectOperations(doc) {
|
|
94
|
+
const out = [];
|
|
95
|
+
const paths = doc.paths ?? {};
|
|
96
|
+
for (const path of Object.keys(paths).sort()) {
|
|
97
|
+
const item = paths[path];
|
|
98
|
+
if (!isRecord(item))
|
|
99
|
+
continue;
|
|
100
|
+
// Path-level parameters apply to every operation on the path (OpenAPI Path Item Object).
|
|
101
|
+
const pathParams = Array.isArray(item.parameters) ? item.parameters : [];
|
|
102
|
+
for (const method of HTTP_METHODS) {
|
|
103
|
+
const opRaw = item[method];
|
|
104
|
+
if (!isRecord(opRaw))
|
|
105
|
+
continue;
|
|
106
|
+
const operationId = typeof opRaw.operationId === "string" ? opRaw.operationId : undefined;
|
|
107
|
+
if (!operationId || !isSafeOperationId(operationId))
|
|
108
|
+
continue;
|
|
109
|
+
const opParams = Array.isArray(opRaw.parameters) ? opRaw.parameters : [];
|
|
110
|
+
const parameters = normalizeParameters([...pathParams, ...opParams]);
|
|
111
|
+
const requestBody = isRecord(opRaw.requestBody) ? opRaw.requestBody : undefined;
|
|
112
|
+
const requestBodySchema = firstJsonSchema(isRecord(requestBody?.content) ? requestBody.content : undefined);
|
|
113
|
+
const responses = isRecord(opRaw.responses) ? opRaw.responses : {};
|
|
114
|
+
out.push({
|
|
115
|
+
operationId,
|
|
116
|
+
method,
|
|
117
|
+
path,
|
|
118
|
+
parameters,
|
|
119
|
+
requestBodySchema,
|
|
120
|
+
requestBodyRequired: requestBody?.required === true,
|
|
121
|
+
responseSchema: firstSuccessSchema(responses),
|
|
122
|
+
eject: opRaw["x-urban-eject"] === true,
|
|
123
|
+
summary: typeof opRaw.summary === "string" ? opRaw.summary : undefined,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/** "method path" pointers for every operation that declares no `operationId`. Such operations are
|
|
130
|
+
* unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
|
|
131
|
+
export function operationsWithoutId(doc) {
|
|
132
|
+
const missing = [];
|
|
133
|
+
const paths = doc.paths ?? {};
|
|
134
|
+
for (const path of Object.keys(paths).sort()) {
|
|
135
|
+
const item = paths[path];
|
|
136
|
+
if (!isRecord(item))
|
|
137
|
+
continue;
|
|
138
|
+
for (const method of HTTP_METHODS) {
|
|
139
|
+
const opRaw = item[method];
|
|
140
|
+
if (!isRecord(opRaw))
|
|
141
|
+
continue;
|
|
142
|
+
if (typeof opRaw.operationId !== "string" || opRaw.operationId.length === 0) {
|
|
143
|
+
missing.push(`${method.toUpperCase()} ${path}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return missing;
|
|
148
|
+
}
|
|
149
|
+
/** "METHOD path {param}" pointers for every path-template parameter that no declared `in: "path"`
|
|
150
|
+
* parameter covers. Such params are still captured and passed to the delegate at runtime, but are
|
|
151
|
+
* neither typed (the deriver only emits declared params) nor validated — a type/runtime drift the
|
|
152
|
+
* runtime mount surfaces at `warn`. */
|
|
153
|
+
export function undeclaredPathParams(doc) {
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const op of collectOperations(doc)) {
|
|
156
|
+
const declared = new Set(op.parameters.filter((p) => p.in === "path").map((p) => p.name));
|
|
157
|
+
for (const m of op.path.matchAll(/\{([^}]+)\}/g)) {
|
|
158
|
+
if (!declared.has(m[1]))
|
|
159
|
+
out.push(`${op.method.toUpperCase()} ${op.path} {${m[1]}}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
/** An operationId names the delegate module file (`<dir>/<operationId>`), so it must be a single
|
|
165
|
+
* safe path segment. Rejects separators and parent-dir traversal so a crafted spec can't import a
|
|
166
|
+
* file outside the operations directory. Single source of truth for the rule (used by the pure
|
|
167
|
+
* collector, the diagnostic collector, and the runtime import sink). */
|
|
168
|
+
export function isSafeOperationId(id) {
|
|
169
|
+
return id.length > 0 && !id.includes("/") && !id.includes("\\") && !id.includes("..") && id !== ".";
|
|
170
|
+
}
|
|
171
|
+
/** "METHOD path (operationId)" pointers for operations whose operationId is present but not a safe
|
|
172
|
+
* path segment. collectOperations skips these (they never mount or drive an import); the runtime
|
|
173
|
+
* surfaces them at `warn`. */
|
|
174
|
+
export function operationsWithUnsafeId(doc) {
|
|
175
|
+
const out = [];
|
|
176
|
+
const paths = doc.paths ?? {};
|
|
177
|
+
for (const path of Object.keys(paths).sort()) {
|
|
178
|
+
const item = paths[path];
|
|
179
|
+
if (!isRecord(item))
|
|
180
|
+
continue;
|
|
181
|
+
for (const method of HTTP_METHODS) {
|
|
182
|
+
const opRaw = item[method];
|
|
183
|
+
if (!isRecord(opRaw))
|
|
184
|
+
continue;
|
|
185
|
+
const id = opRaw.operationId;
|
|
186
|
+
if (typeof id === "string" && id.length > 0 && !isSafeOperationId(id)) {
|
|
187
|
+
out.push(`${method.toUpperCase()} ${path} (${id})`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
function normalizeParameters(raw) {
|
|
194
|
+
// OpenAPI: parameters are uniquely identified by (name, in). A path-level parameter can be
|
|
195
|
+
// overridden by an operation-level one with the same (name, in) — never duplicated. Callers pass
|
|
196
|
+
// [...pathParams, ...opParams], so dedup later-wins (op overrides path) while preserving the
|
|
197
|
+
// first-seen position, so the emitted binding never carries two `path`/`query` fields with the
|
|
198
|
+
// same name (which would collide in the generated params/query types and in runtime extraction).
|
|
199
|
+
const byKey = new Map();
|
|
200
|
+
const order = [];
|
|
201
|
+
for (const p of raw) {
|
|
202
|
+
if (!isRecord(p))
|
|
203
|
+
continue;
|
|
204
|
+
const name = typeof p.name === "string" ? p.name : undefined;
|
|
205
|
+
const location = typeof p.in === "string" ? p.in : undefined;
|
|
206
|
+
if (!name || !location)
|
|
207
|
+
continue;
|
|
208
|
+
if (location !== "path" && location !== "query" && location !== "header" && location !== "cookie") {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const schema = isRecord(p.schema) ? p.schema : undefined;
|
|
212
|
+
const key = `${location}\u0000${name}`;
|
|
213
|
+
if (!byKey.has(key))
|
|
214
|
+
order.push(key);
|
|
215
|
+
byKey.set(key, { name, in: location, required: p.required === true, schema });
|
|
216
|
+
}
|
|
217
|
+
return order.map((k) => byKey.get(k));
|
|
218
|
+
}
|
|
219
|
+
function firstSuccessSchema(responses) {
|
|
220
|
+
const codes = Object.keys(responses)
|
|
221
|
+
.filter((c) => /^2\d\d$/.test(c))
|
|
222
|
+
.sort();
|
|
223
|
+
const code = codes[0] ?? (isRecord(responses.default) ? "default" : undefined);
|
|
224
|
+
if (!code)
|
|
225
|
+
return undefined;
|
|
226
|
+
const resp = responses[code];
|
|
227
|
+
if (!isRecord(resp))
|
|
228
|
+
return undefined;
|
|
229
|
+
return firstJsonSchema(isRecord(resp.content) ? resp.content : undefined);
|
|
230
|
+
}
|
|
231
|
+
/** Turn an OpenAPI path template ("/invoices/{id}") into a matcher under `base` ("/app/api"):
|
|
232
|
+
* a RegExp that captures each `{param}` positionally, plus the ordered path-parameter names. The
|
|
233
|
+
* runtime matches concrete request paths, so a templated segment becomes a single-segment capture. */
|
|
234
|
+
/** Escape RegExp metacharacters so a string is matched literally inside a `new RegExp(...)`. */
|
|
235
|
+
export function escapeRegex(s) {
|
|
236
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
237
|
+
}
|
|
238
|
+
export function toRouteMatcher(base, path) {
|
|
239
|
+
const cleanBase = base.replace(/\/+$/, "");
|
|
240
|
+
const paramNames = [];
|
|
241
|
+
const segments = path.split("/").filter((s) => s.length > 0);
|
|
242
|
+
const regexParts = segments.map((seg) => {
|
|
243
|
+
const m = seg.match(/^\{([^}]+)\}$/);
|
|
244
|
+
if (m) {
|
|
245
|
+
paramNames.push(m[1]);
|
|
246
|
+
return "([^/]+)";
|
|
247
|
+
}
|
|
248
|
+
return escapeRegex(seg);
|
|
249
|
+
});
|
|
250
|
+
const body = regexParts.length > 0 ? `/${regexParts.join("/")}` : "";
|
|
251
|
+
// `base` is a literal path prefix (e.g. "/app/api(v2)"), not a pattern — escape its metacharacters
|
|
252
|
+
// just like the static template segments so it can't alter or break the matcher.
|
|
253
|
+
const pattern = new RegExp(`^${escapeRegex(cleanBase)}${body}/?$`);
|
|
254
|
+
const sample = `${cleanBase}${segments.length > 0 ? `/${segments.join("/")}` : ""}`;
|
|
255
|
+
return { pattern, paramNames, sample };
|
|
256
|
+
}
|
|
257
|
+
/** Structural (deep) equality for JSON values — enum/const members can be objects or arrays, which
|
|
258
|
+
* a parsed request will never be `===` to. Compares by shape so those comparisons are correct. */
|
|
259
|
+
function deepEqual(a, b) {
|
|
260
|
+
if (a === b)
|
|
261
|
+
return true;
|
|
262
|
+
if (typeof a !== typeof b || a === null || b === null)
|
|
263
|
+
return false;
|
|
264
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
265
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
266
|
+
return false;
|
|
267
|
+
return a.every((x, i) => deepEqual(x, b[i]));
|
|
268
|
+
}
|
|
269
|
+
if (isRecord(a) && isRecord(b)) {
|
|
270
|
+
const ak = Object.keys(a);
|
|
271
|
+
const bk = Object.keys(b);
|
|
272
|
+
if (ak.length !== bk.length)
|
|
273
|
+
return false;
|
|
274
|
+
return ak.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
|
|
275
|
+
}
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
function typeOfValue(v) {
|
|
279
|
+
if (Array.isArray(v))
|
|
280
|
+
return "array";
|
|
281
|
+
if (typeof v === "number" && Number.isInteger(v))
|
|
282
|
+
return "integer";
|
|
283
|
+
return typeof v;
|
|
284
|
+
}
|
|
285
|
+
function matchesType(declared, value) {
|
|
286
|
+
const actual = typeOfValue(value);
|
|
287
|
+
if (declared === "number")
|
|
288
|
+
return actual === "number" || actual === "integer";
|
|
289
|
+
if (declared === "integer")
|
|
290
|
+
return actual === "integer";
|
|
291
|
+
return actual === declared;
|
|
292
|
+
}
|
|
293
|
+
/** Validate `value` against `schema` (resolving local $refs against `doc`), returning every issue
|
|
294
|
+
* found (empty = valid). A pragmatic JSON-Schema subset; unknown keywords are ignored. */
|
|
295
|
+
export function validateValue(doc, schemaIn, value, path = "") {
|
|
296
|
+
const schema = resolveSchema(doc, schemaIn);
|
|
297
|
+
if (!schema)
|
|
298
|
+
return [];
|
|
299
|
+
const issues = [];
|
|
300
|
+
const at = path || "(root)";
|
|
301
|
+
if (value === null) {
|
|
302
|
+
if (schema.nullable === true || schema.type === "null")
|
|
303
|
+
return issues;
|
|
304
|
+
// enum/const may explicitly permit null even without `nullable`.
|
|
305
|
+
if (schema.enum && schema.enum.some((e) => e === null))
|
|
306
|
+
return issues;
|
|
307
|
+
if (schema.const === null)
|
|
308
|
+
return issues;
|
|
309
|
+
// Otherwise null is invalid for a typed schema, an object schema (properties/required imply
|
|
310
|
+
// object), or an enum/const that doesn't list null. A truly empty schema ({}) allows anything.
|
|
311
|
+
if (schema.enum) {
|
|
312
|
+
issues.push({ path: at, message: `must be one of ${JSON.stringify(schema.enum)}` });
|
|
313
|
+
}
|
|
314
|
+
else if (schema.const !== undefined) {
|
|
315
|
+
issues.push({ path: at, message: `must equal ${JSON.stringify(schema.const)}` });
|
|
316
|
+
}
|
|
317
|
+
else if (schema.type) {
|
|
318
|
+
issues.push({ path: at, message: `expected ${schema.type}, got null` });
|
|
319
|
+
}
|
|
320
|
+
else if (isObjectSchema(schema)) {
|
|
321
|
+
issues.push({ path: at, message: "expected object, got null" });
|
|
322
|
+
}
|
|
323
|
+
return issues;
|
|
324
|
+
}
|
|
325
|
+
if (schema.enum && !schema.enum.some((e) => deepEqual(e, value))) {
|
|
326
|
+
issues.push({ path: at, message: `must be one of ${JSON.stringify(schema.enum)}` });
|
|
327
|
+
}
|
|
328
|
+
if (schema.const !== undefined && !deepEqual(schema.const, value)) {
|
|
329
|
+
issues.push({ path: at, message: `must equal ${JSON.stringify(schema.const)}` });
|
|
330
|
+
}
|
|
331
|
+
// Composition keywords — kept in lockstep with schemaToTs, which emits `allOf` as an intersection
|
|
332
|
+
// and `anyOf`/`oneOf` as unions. Runs before the `type` early-return since a composed schema may
|
|
333
|
+
// omit a top-level `type` and carry it on the sub-schemas instead.
|
|
334
|
+
if (schema.allOf) {
|
|
335
|
+
for (const sub of schema.allOf)
|
|
336
|
+
issues.push(...validateValue(doc, sub, value, path));
|
|
337
|
+
}
|
|
338
|
+
if (schema.anyOf && !schema.anyOf.some((sub) => validateValue(doc, sub, value, path).length === 0)) {
|
|
339
|
+
issues.push({ path: at, message: "does not match any schema in anyOf" });
|
|
340
|
+
}
|
|
341
|
+
if (schema.oneOf) {
|
|
342
|
+
const matched = schema.oneOf.filter((sub) => validateValue(doc, sub, value, path).length === 0).length;
|
|
343
|
+
if (matched !== 1) {
|
|
344
|
+
issues.push({ path: at, message: `must match exactly one schema in oneOf (matched ${matched})` });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (schema.type && !matchesType(schema.type, value)) {
|
|
348
|
+
issues.push({ path: at, message: `expected ${schema.type}, got ${typeOfValue(value)}` });
|
|
349
|
+
return issues; // the shape checks below assume the declared type held
|
|
350
|
+
}
|
|
351
|
+
if (schema.type === "string" && typeof value === "string") {
|
|
352
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
|
353
|
+
issues.push({ path: at, message: `shorter than minLength ${schema.minLength}` });
|
|
354
|
+
}
|
|
355
|
+
if (schema.maxLength !== undefined && value.length > schema.maxLength) {
|
|
356
|
+
issues.push({ path: at, message: `longer than maxLength ${schema.maxLength}` });
|
|
357
|
+
}
|
|
358
|
+
if (schema.pattern) {
|
|
359
|
+
let re;
|
|
360
|
+
try {
|
|
361
|
+
re = new RegExp(schema.pattern);
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
// A malformed pattern is a spec-author error, not client input. Surface it as a
|
|
365
|
+
// controlled failure (the dispatcher's outer catch maps it to a 500 with this
|
|
366
|
+
// message) instead of letting `new RegExp` throw an opaque error deep in
|
|
367
|
+
// validation and crash the host request handler.
|
|
368
|
+
throw new Error(`schema at ${at}: invalid pattern ${JSON.stringify(schema.pattern)}`);
|
|
369
|
+
}
|
|
370
|
+
if (!re.test(value)) {
|
|
371
|
+
issues.push({ path: at, message: `does not match pattern ${schema.pattern}` });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if ((schema.type === "number" || schema.type === "integer") && typeof value === "number") {
|
|
376
|
+
// OpenAPI 3.0 uses the boolean form (`exclusiveMinimum: true` makes `minimum` exclusive);
|
|
377
|
+
// JSON Schema 2020-12 (OpenAPI 3.1) uses the numeric form (`exclusiveMinimum: <number>`).
|
|
378
|
+
// Support both so specs in either dialect are validated, not silently under-checked.
|
|
379
|
+
const minExclusive = schema.exclusiveMinimum === true;
|
|
380
|
+
const maxExclusive = schema.exclusiveMaximum === true;
|
|
381
|
+
if (schema.minimum !== undefined && (minExclusive ? value <= schema.minimum : value < schema.minimum)) {
|
|
382
|
+
issues.push({
|
|
383
|
+
path: at,
|
|
384
|
+
message: minExclusive ? `must be > ${schema.minimum}` : `less than minimum ${schema.minimum}`,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
if (schema.maximum !== undefined && (maxExclusive ? value >= schema.maximum : value > schema.maximum)) {
|
|
388
|
+
issues.push({
|
|
389
|
+
path: at,
|
|
390
|
+
message: maxExclusive ? `must be < ${schema.maximum}` : `greater than maximum ${schema.maximum}`,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
if (typeof schema.exclusiveMinimum === "number" && value <= schema.exclusiveMinimum) {
|
|
394
|
+
issues.push({ path: at, message: `must be > ${schema.exclusiveMinimum}` });
|
|
395
|
+
}
|
|
396
|
+
if (typeof schema.exclusiveMaximum === "number" && value >= schema.exclusiveMaximum) {
|
|
397
|
+
issues.push({ path: at, message: `must be < ${schema.exclusiveMaximum}` });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (schema.type === "array" && Array.isArray(value)) {
|
|
401
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
|
402
|
+
issues.push({ path: at, message: `fewer than minItems ${schema.minItems}` });
|
|
403
|
+
}
|
|
404
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) {
|
|
405
|
+
issues.push({ path: at, message: `more than maxItems ${schema.maxItems}` });
|
|
406
|
+
}
|
|
407
|
+
if (schema.items) {
|
|
408
|
+
value.forEach((item, i) => {
|
|
409
|
+
issues.push(...validateValue(doc, schema.items, item, `${path}[${i}]`));
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (isObjectSchema(schema)) {
|
|
414
|
+
if (!isRecord(value)) {
|
|
415
|
+
// A typeless object schema (properties/required imply object, but `type` is omitted) skips
|
|
416
|
+
// the type check above, so guard here — otherwise a non-object like `42` or `[]` would pass.
|
|
417
|
+
issues.push({ path: at, message: `expected object, got ${typeOfValue(value)}` });
|
|
418
|
+
return issues;
|
|
419
|
+
}
|
|
420
|
+
for (const req of schema.required ?? []) {
|
|
421
|
+
if (!Object.hasOwn(value, req))
|
|
422
|
+
issues.push({ path: `${path}/${req}`, message: "is required" });
|
|
423
|
+
}
|
|
424
|
+
const props = schema.properties ?? {};
|
|
425
|
+
for (const [key, propSchema] of Object.entries(props)) {
|
|
426
|
+
if (Object.hasOwn(value, key))
|
|
427
|
+
issues.push(...validateValue(doc, propSchema, value[key], `${path}/${key}`));
|
|
428
|
+
}
|
|
429
|
+
const extra = schema.additionalProperties;
|
|
430
|
+
if (extra === false) {
|
|
431
|
+
for (const key of Object.keys(value)) {
|
|
432
|
+
if (!Object.hasOwn(props, key))
|
|
433
|
+
issues.push({ path: `${path}/${key}`, message: "is not an allowed property" });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
else if (isRecord(extra)) {
|
|
437
|
+
for (const key of Object.keys(value)) {
|
|
438
|
+
if (!Object.hasOwn(props, key))
|
|
439
|
+
issues.push(...validateValue(doc, extra, value[key], `${path}/${key}`));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return issues;
|
|
444
|
+
}
|
|
@@ -13,6 +13,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
13
13
|
import { isAbsolute, resolve } from "node:path";
|
|
14
14
|
import { pathToFileURL } from "node:url";
|
|
15
15
|
import { DatabaseSync } from "node:sqlite";
|
|
16
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
16
17
|
function sqliteParams(params) {
|
|
17
18
|
return params.map((param) => {
|
|
18
19
|
if (param === null ||
|
|
@@ -103,6 +104,10 @@ export function createDenoHost(opts = {}) {
|
|
|
103
104
|
};
|
|
104
105
|
},
|
|
105
106
|
now: () => Date.now(),
|
|
107
|
+
createAsyncStore() {
|
|
108
|
+
const als = new AsyncLocalStorage();
|
|
109
|
+
return { run: (value, fn) => als.run(value, fn), current: () => als.getStore() };
|
|
110
|
+
},
|
|
106
111
|
log,
|
|
107
112
|
};
|
|
108
113
|
}
|
|
@@ -16,6 +16,7 @@ import { register } from "node:module";
|
|
|
16
16
|
import { isAbsolute, resolve } from "node:path";
|
|
17
17
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
18
|
import { DatabaseSync } from "node:sqlite";
|
|
19
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
19
20
|
function sqliteParams(params) {
|
|
20
21
|
return params.map((param) => {
|
|
21
22
|
if (param === null ||
|
|
@@ -154,6 +155,10 @@ export function createNodeHost(opts = {}) {
|
|
|
154
155
|
return { close: () => w.close() };
|
|
155
156
|
},
|
|
156
157
|
now: () => Date.now(),
|
|
158
|
+
createAsyncStore() {
|
|
159
|
+
const als = new AsyncLocalStorage();
|
|
160
|
+
return { run: (value, fn) => als.run(value, fn), current: () => als.getStore() };
|
|
161
|
+
},
|
|
157
162
|
log,
|
|
158
163
|
};
|
|
159
164
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AsyncStore } from "./host.ts";
|
|
2
|
+
/** The ambient context of the job currently executing on a worker. All fields are optional so a
|
|
3
|
+
* job dispatched without an instance/element still runs; provenance is only recorded when an
|
|
4
|
+
* `instanceKey` is present. */
|
|
5
|
+
export interface JobExecContext {
|
|
6
|
+
/** The process instance that dispatched the running job. */
|
|
7
|
+
instanceKey?: string;
|
|
8
|
+
/** The BPMN element (e.g. service task) the job is for. */
|
|
9
|
+
elementId?: string;
|
|
10
|
+
/** The job type (worker) currently executing. */
|
|
11
|
+
jobType?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Install the host-backed ambient store once per process. Idempotent — the first store created
|
|
15
|
+
* wins: every Node/Deno store is an equivalent `AsyncLocalStorage` instance, and a process that
|
|
16
|
+
* hosts several apps shares one async store safely (values are keyed by async execution context,
|
|
17
|
+
* not by app). A `factory` that returns undefined (a host without the capability) leaves capture
|
|
18
|
+
* disabled, but a later capable host may still install the process store.
|
|
19
|
+
*/
|
|
20
|
+
export declare function installExecStore(factory: () => AsyncStore<JobExecContext> | undefined): void;
|
|
21
|
+
/** Run `fn` with `ctx` as the ambient job context. A transparent pass-through when no store is
|
|
22
|
+
* installed (returns `fn()` directly). */
|
|
23
|
+
export declare function runInJobContext<R>(ctx: JobExecContext, fn: () => R): R;
|
|
24
|
+
/** The ambient job context of the current async execution, or undefined outside a job (or when
|
|
25
|
+
* no store is installed). */
|
|
26
|
+
export declare function currentJobContext(): JobExecContext | undefined;
|
|
27
|
+
/** Test seam: drop the installed store so a subsequent {@link installExecStore} re-installs. */
|
|
28
|
+
export declare function __resetExecStoreForTests(): void;
|