vouchington-tooling 0.0.9 → 0.0.11
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/README.md +21 -1
- package/dist/cli/commands/gha-artifacts-cleanup.d.mts +6 -0
- package/dist/cli/commands/gha-artifacts-cleanup.mjs +50 -0
- package/dist/cli/commands/http-origin.d.mts +2 -0
- package/dist/cli/commands/http-origin.mjs +14 -0
- package/dist/cli/index.mjs +11 -0
- package/dist/cli/parse-gha-artifacts-cleanup.d.mts +15 -0
- package/dist/cli/parse-gha-artifacts-cleanup.mjs +74 -0
- package/dist/cli/parse.d.mts +7 -2
- package/dist/cli/parse.mjs +36 -0
- package/dist/cli/usage.d.mts +1 -1
- package/dist/cli/usage.mjs +9 -0
- package/dist/gha-artifacts-cleanup/classify.d.mts +14 -0
- package/dist/gha-artifacts-cleanup/classify.mjs +32 -0
- package/dist/gha-artifacts-cleanup/commands.d.mts +23 -0
- package/dist/gha-artifacts-cleanup/commands.mjs +55 -0
- package/dist/gha-artifacts-cleanup/github.d.mts +16 -0
- package/dist/gha-artifacts-cleanup/github.mjs +100 -0
- package/dist/gha-artifacts-cleanup/index.d.mts +8 -0
- package/dist/gha-artifacts-cleanup/index.mjs +4 -0
- package/dist/gha-artifacts-cleanup/plan.d.mts +17 -0
- package/dist/gha-artifacts-cleanup/plan.mjs +40 -0
- package/dist/gha-selected-files/index.d.mts +6 -0
- package/dist/gha-selected-files/index.mjs +38 -0
- package/dist/http-origin/index.d.mts +1 -0
- package/dist/http-origin/index.mjs +23 -0
- package/dist/index.d.mts +11 -2
- package/dist/index.mjs +7 -1
- package/dist/openapi-document/build-openapi-document.d.mts +11 -0
- package/dist/openapi-document/build-openapi-document.mjs +122 -0
- package/dist/openapi-document/build-openapi-query.d.mts +3 -0
- package/dist/openapi-document/build-openapi-query.mjs +53 -0
- package/dist/openapi-document/build-openapi-request.d.mts +7 -0
- package/dist/openapi-document/build-openapi-request.mjs +29 -0
- package/dist/openapi-document/build-openapi-response.d.mts +9 -0
- package/dist/openapi-document/build-openapi-response.mjs +111 -0
- package/dist/openapi-document/component-registry.d.mts +9 -0
- package/dist/openapi-document/component-registry.mjs +49 -0
- package/dist/openapi-document/contract-schema-canonical.d.mts +4 -0
- package/dist/openapi-document/contract-schema-canonical.mjs +23 -0
- package/dist/openapi-document/contract-schema-intersection-merge.d.mts +13 -0
- package/dist/openapi-document/contract-schema-intersection-merge.mjs +96 -0
- package/dist/openapi-document/contract-schema-to-openapi.d.mts +7 -0
- package/dist/openapi-document/contract-schema-to-openapi.mjs +71 -0
- package/dist/openapi-document/contract-schema-types.d.mts +43 -0
- package/dist/openapi-document/contract-schema-types.mjs +1 -0
- package/dist/openapi-document/index.d.mts +19 -0
- package/dist/openapi-document/index.mjs +12 -0
- package/dist/openapi-document/openapi-error-response.d.mts +2 -0
- package/dist/openapi-document/openapi-error-response.mjs +13 -0
- package/dist/openapi-document/openapi-route-helpers.d.mts +9 -0
- package/dist/openapi-document/openapi-route-helpers.mjs +61 -0
- package/dist/openapi-document/openapi-types.d.mts +80 -0
- package/dist/openapi-document/openapi-types.mjs +1 -0
- package/dist/openapi-document/operation-types.d.mts +35 -0
- package/dist/openapi-document/operation-types.mjs +11 -0
- package/dist/openapi-document/query-types.d.mts +35 -0
- package/dist/openapi-document/query-types.mjs +1 -0
- package/dist/openapi-document/schema-node-builders.d.mts +3 -0
- package/dist/openapi-document/schema-node-builders.mjs +6 -0
- package/dist/openapi-document/write-openapi.d.mts +9 -0
- package/dist/openapi-document/write-openapi.mjs +19 -0
- package/dist/pg-schema-snapshot/build-snapshot.d.mts +9 -0
- package/dist/pg-schema-snapshot/build-snapshot.mjs +61 -0
- package/dist/pg-schema-snapshot/catalog-objects.d.mts +34 -0
- package/dist/pg-schema-snapshot/catalog-objects.mjs +64 -0
- package/dist/pg-schema-snapshot/catalog-queries.d.mts +20 -0
- package/dist/pg-schema-snapshot/catalog-queries.mjs +31 -0
- package/dist/pg-schema-snapshot/catalog-shared.d.mts +3 -0
- package/dist/pg-schema-snapshot/catalog-shared.mjs +19 -0
- package/dist/pg-schema-snapshot/catalog-table-constraints.d.mts +14 -0
- package/dist/pg-schema-snapshot/catalog-table-constraints.mjs +54 -0
- package/dist/pg-schema-snapshot/catalog-table-indexes.d.mts +30 -0
- package/dist/pg-schema-snapshot/catalog-table-indexes.mjs +81 -0
- package/dist/pg-schema-snapshot/catalog-tables.d.mts +22 -0
- package/dist/pg-schema-snapshot/catalog-tables.mjs +48 -0
- package/dist/pg-schema-snapshot/file-safety.d.mts +4 -0
- package/dist/pg-schema-snapshot/file-safety.mjs +71 -0
- package/dist/pg-schema-snapshot/generate.d.mts +18 -0
- package/dist/pg-schema-snapshot/generate.mjs +95 -0
- package/dist/pg-schema-snapshot/index-rename-detect.d.mts +40 -0
- package/dist/pg-schema-snapshot/index-rename-detect.mjs +71 -0
- package/dist/pg-schema-snapshot/index.d.mts +10 -0
- package/dist/pg-schema-snapshot/index.mjs +6 -0
- package/dist/pg-schema-snapshot/markdown-files.d.mts +1 -0
- package/dist/pg-schema-snapshot/markdown-files.mjs +26 -0
- package/dist/pg-schema-snapshot/render-markdown.d.mts +8 -0
- package/dist/pg-schema-snapshot/render-markdown.mjs +113 -0
- package/dist/pg-schema-snapshot/render-tables.d.mts +2 -0
- package/dist/pg-schema-snapshot/render-tables.mjs +55 -0
- package/dist/pg-schema-snapshot/snapshot-build-utils.d.mts +2 -0
- package/dist/pg-schema-snapshot/snapshot-build-utils.mjs +15 -0
- package/dist/pg-schema-snapshot/snapshot.test-helpers.d.mts +11 -0
- package/dist/pg-schema-snapshot/snapshot.test-helpers.mjs +101 -0
- package/dist/pg-schema-snapshot/table-snapshot-columns.d.mts +3 -0
- package/dist/pg-schema-snapshot/table-snapshot-columns.mjs +33 -0
- package/dist/pg-schema-snapshot/table-snapshot-constraints.d.mts +7 -0
- package/dist/pg-schema-snapshot/table-snapshot-constraints.mjs +55 -0
- package/dist/pg-schema-snapshot/table-snapshot.d.mts +10 -0
- package/dist/pg-schema-snapshot/table-snapshot.mjs +99 -0
- package/dist/pg-schema-snapshot/types.d.mts +103 -0
- package/dist/pg-schema-snapshot/types.mjs +1 -0
- package/dist/process-line-buffer/index.d.mts +7 -0
- package/dist/process-line-buffer/index.mjs +28 -0
- package/dist/shared-context/{fake-git.test-helpers.d.mts → fake-git.d.mts} +1 -0
- package/dist/shared-context/{fake-git.test-helpers.mjs → fake-git.mjs} +4 -1
- package/dist/shared-context/index.d.mts +2 -0
- package/dist/shared-context/index.mjs +1 -0
- package/package.json +37 -1
- package/scripts/gha/diagnose-port-collision.sh +232 -0
- package/scripts/gha/prepare-trivy-db.sh +32 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { hashContractSchema } from './contract-schema-canonical.mjs';
|
|
2
|
+
/**
|
|
3
|
+
* Structurally merges an all-object intersection instead of emitting raw `allOf`: the
|
|
4
|
+
* extractor defaults `additionalProperties:false`, so `allOf` of two closed objects is
|
|
5
|
+
* unsatisfiable. Falls back to raw `allOf` only when a member isn't an object (directly or
|
|
6
|
+
* via `ref`), where that unsatisfiability concern doesn't apply. `nodeToOpenApi` is passed in
|
|
7
|
+
* (rather than imported) so this module doesn't form an import cycle with the dispatcher.
|
|
8
|
+
*/
|
|
9
|
+
export function intersectionToOpenApi(node, ctx, nodeToOpenApi) {
|
|
10
|
+
const resolved = node.variants.map((variant) => resolveObjectVariant(variant, ctx.definitions));
|
|
11
|
+
if (resolved.some((variant) => variant === undefined)) {
|
|
12
|
+
return { allOf: node.variants.map((variant) => nodeToOpenApi(variant, ctx)) };
|
|
13
|
+
}
|
|
14
|
+
const properties = {};
|
|
15
|
+
for (const variant of resolved) {
|
|
16
|
+
for (const [key, property] of Object.entries(variant.properties)) {
|
|
17
|
+
const existing = properties[key];
|
|
18
|
+
if (existing &&
|
|
19
|
+
hashSchemaNode(existing.schema, ctx) !== hashSchemaNode(property.schema, ctx)) {
|
|
20
|
+
// A member re-declaring a shared key with a strictly narrower type is a common, valid
|
|
21
|
+
// TypeScript intersection idiom (e.g. `Omit`-free overriding of an optional/nullable
|
|
22
|
+
// field to a guaranteed-present one) — TypeScript itself resolves the property to the
|
|
23
|
+
// narrower type, not a conflict. Prefer whichever side is the narrower one; only two
|
|
24
|
+
// genuinely incompatible schemas (neither a subset of the other) are a real conflict.
|
|
25
|
+
if (isNarrowerVariant(property.schema, existing.schema, ctx)) {
|
|
26
|
+
properties[key] = {
|
|
27
|
+
schema: property.schema,
|
|
28
|
+
required: existing.required || property.required,
|
|
29
|
+
};
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
/* v8 ignore next 6 -- symmetric of the previous branch; covered by order-swapped fixtures */
|
|
33
|
+
if (isNarrowerVariant(existing.schema, property.schema, ctx)) {
|
|
34
|
+
properties[key] = {
|
|
35
|
+
schema: existing.schema,
|
|
36
|
+
required: existing.required || property.required,
|
|
37
|
+
};
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`Cannot merge intersection: property "${key}" has conflicting schemas across members`);
|
|
41
|
+
}
|
|
42
|
+
properties[key] = {
|
|
43
|
+
schema: property.schema,
|
|
44
|
+
required: existing?.required || property.required,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const sortedKeys = Object.keys(properties).toSorted();
|
|
49
|
+
const required = sortedKeys.filter((key) => properties[key].required);
|
|
50
|
+
return {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: Object.fromEntries(sortedKeys.map((key) => [key, nodeToOpenApi(properties[key].schema, ctx)])),
|
|
53
|
+
...(required.length > 0 ? { required } : {}),
|
|
54
|
+
additionalProperties: false,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// Bounds intersection-merge ref resolution so a cyclic or pathologically deep `definitions`
|
|
58
|
+
// chain can't hang the converter; falls back to raw `allOf` if the object is still unresolved
|
|
59
|
+
// past this many hops (comfortably above the extractor's actual definition-nesting depths).
|
|
60
|
+
const MAX_REF_RESOLUTION_HOPS = 5;
|
|
61
|
+
function resolveObjectVariant(node, definitions) {
|
|
62
|
+
const resolved = resolveRef(node, definitions);
|
|
63
|
+
return resolved.type === 'object' ? resolved : undefined;
|
|
64
|
+
}
|
|
65
|
+
/** Follows a `ref` chain (bounded by `MAX_REF_RESOLUTION_HOPS`) to its underlying node, whatever shape that turns out to be. Returns the node unchanged if it isn't a `ref`, or if the chain doesn't resolve within the hop budget. */
|
|
66
|
+
function resolveRef(node, definitions) {
|
|
67
|
+
let current = node;
|
|
68
|
+
for (let hop = 0; hop < MAX_REF_RESOLUTION_HOPS && current.type === 'ref'; hop++) {
|
|
69
|
+
const next = definitions[current.name];
|
|
70
|
+
if (!next)
|
|
71
|
+
return current;
|
|
72
|
+
current = next;
|
|
73
|
+
}
|
|
74
|
+
return current;
|
|
75
|
+
}
|
|
76
|
+
function hashSchemaNode(node, ctx) {
|
|
77
|
+
return hashContractSchema({ root: node, definitions: ctx.definitions });
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* True when every value `narrow` accepts is also accepted by `wide` — i.e. `narrow`'s own variant
|
|
81
|
+
* set (a non-union schema is its own single-variant set) is a subset of `wide`'s. Covers the
|
|
82
|
+
* realistic case this converter needs to handle (an intersection member narrowing a shared
|
|
83
|
+
* optional/nullable property to a guaranteed-present one, e.g. `string | null` narrowed to
|
|
84
|
+
* `string`) without attempting full structural subtyping for arbitrary object shapes.
|
|
85
|
+
*
|
|
86
|
+
* Both sides are `ref`-resolved first: a shared property declared with its own named union alias
|
|
87
|
+
* (e.g. `post_type: PostType`) extracts as a `ref` to that alias, not an inline `union` node, so
|
|
88
|
+
* checking `.type === 'union'` on the raw node would miss it entirely.
|
|
89
|
+
*/
|
|
90
|
+
function isNarrowerVariant(narrow, wide, ctx) {
|
|
91
|
+
const resolvedNarrow = resolveRef(narrow, ctx.definitions);
|
|
92
|
+
const resolvedWide = resolveRef(wide, ctx.definitions);
|
|
93
|
+
const narrowVariants = resolvedNarrow.type === 'union' ? resolvedNarrow.variants : [resolvedNarrow];
|
|
94
|
+
const wideHashes = new Set((resolvedWide.type === 'union' ? resolvedWide.variants : [resolvedWide]).map((variant) => hashSchemaNode(variant, ctx)));
|
|
95
|
+
return narrowVariants.every((variant) => wideHashes.has(hashSchemaNode(variant, ctx)));
|
|
96
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ContractSchemaNode } from './contract-schema-types.mts';
|
|
2
|
+
import type { OpenApiSchema } from './openapi-types.mts';
|
|
3
|
+
export type OpenApiConverterContext = {
|
|
4
|
+
definitions: Record<string, ContractSchemaNode>;
|
|
5
|
+
refName: (name: string) => string;
|
|
6
|
+
};
|
|
7
|
+
export declare function nodeToOpenApi(node: ContractSchemaNode, ctx: OpenApiConverterContext): OpenApiSchema;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { intersectionToOpenApi } from './contract-schema-intersection-merge.mjs';
|
|
2
|
+
export function nodeToOpenApi(node, ctx) {
|
|
3
|
+
switch (node.type) {
|
|
4
|
+
case 'unknown':
|
|
5
|
+
return {};
|
|
6
|
+
case 'null':
|
|
7
|
+
return { type: 'null' };
|
|
8
|
+
case 'boolean':
|
|
9
|
+
case 'number':
|
|
10
|
+
case 'string':
|
|
11
|
+
return { type: node.type };
|
|
12
|
+
case 'literal':
|
|
13
|
+
return { const: node.value };
|
|
14
|
+
case 'array':
|
|
15
|
+
return { type: 'array', items: nodeToOpenApi(node.items, ctx) };
|
|
16
|
+
case 'tuple':
|
|
17
|
+
return tupleToOpenApi(node, ctx);
|
|
18
|
+
case 'object':
|
|
19
|
+
return objectToOpenApi(node, ctx);
|
|
20
|
+
case 'union':
|
|
21
|
+
return unionToOpenApi(node, ctx);
|
|
22
|
+
case 'intersection':
|
|
23
|
+
return intersectionToOpenApi(node, ctx, nodeToOpenApi);
|
|
24
|
+
case 'ref':
|
|
25
|
+
return { $ref: `#/components/schemas/${ctx.refName(node.name)}` };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function tupleToOpenApi(node, ctx) {
|
|
29
|
+
const prefixItems = node.items.map((item) => nodeToOpenApi(item, ctx));
|
|
30
|
+
const minItems = node.items.length - node.optionalItems;
|
|
31
|
+
if (node.rest) {
|
|
32
|
+
return { type: 'array', prefixItems, minItems, items: nodeToOpenApi(node.rest, ctx) };
|
|
33
|
+
}
|
|
34
|
+
return { type: 'array', prefixItems, minItems, items: false, maxItems: node.items.length };
|
|
35
|
+
}
|
|
36
|
+
function objectToOpenApi(node, ctx) {
|
|
37
|
+
const keys = Object.keys(node.properties).toSorted();
|
|
38
|
+
const properties = {};
|
|
39
|
+
const required = [];
|
|
40
|
+
for (const key of keys) {
|
|
41
|
+
const property = node.properties[key];
|
|
42
|
+
properties[key] = nodeToOpenApi(property.schema, ctx);
|
|
43
|
+
if (property.required)
|
|
44
|
+
required.push(key);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties,
|
|
49
|
+
...(required.length > 0 ? { required } : {}),
|
|
50
|
+
additionalProperties: node.additionalProperties === false ? false : nodeToOpenApi(node.additionalProperties, ctx),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const LITERAL_TYPES = { boolean: 'boolean', number: 'number', string: 'string' };
|
|
54
|
+
function unionToOpenApi(node, ctx) {
|
|
55
|
+
return (literalUnionEnum(node) ?? { anyOf: node.variants.map((variant) => nodeToOpenApi(variant, ctx)) });
|
|
56
|
+
}
|
|
57
|
+
function literalUnionEnum(node) {
|
|
58
|
+
if (node.variants.length === 0)
|
|
59
|
+
return undefined;
|
|
60
|
+
if (!node.variants.every((variant) => variant.type === 'literal'))
|
|
61
|
+
return undefined;
|
|
62
|
+
const literals = node.variants;
|
|
63
|
+
const type = LITERAL_TYPES[typeof literals[0].value];
|
|
64
|
+
const sameType = literals.every((literal) => LITERAL_TYPES[typeof literal.value] === type);
|
|
65
|
+
if (!sameType)
|
|
66
|
+
return undefined;
|
|
67
|
+
const values = [...new Set(literals.map((literal) => literal.value))].toSorted((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
68
|
+
if (type === 'boolean' && values.length === 2)
|
|
69
|
+
return { type: 'boolean' };
|
|
70
|
+
return { type, enum: values };
|
|
71
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type ContractSchema = {
|
|
2
|
+
root: ContractSchemaNode;
|
|
3
|
+
definitions: Record<string, ContractSchemaNode>;
|
|
4
|
+
};
|
|
5
|
+
export type ContractSchemaNode = {
|
|
6
|
+
type: 'unknown';
|
|
7
|
+
} | {
|
|
8
|
+
type: 'null';
|
|
9
|
+
} | {
|
|
10
|
+
type: 'boolean';
|
|
11
|
+
} | {
|
|
12
|
+
type: 'number';
|
|
13
|
+
} | {
|
|
14
|
+
type: 'string';
|
|
15
|
+
} | {
|
|
16
|
+
type: 'literal';
|
|
17
|
+
value: boolean | number | string;
|
|
18
|
+
} | {
|
|
19
|
+
type: 'array';
|
|
20
|
+
items: ContractSchemaNode;
|
|
21
|
+
} | {
|
|
22
|
+
type: 'tuple';
|
|
23
|
+
items: ContractSchemaNode[];
|
|
24
|
+
optionalItems: number;
|
|
25
|
+
rest?: ContractSchemaNode;
|
|
26
|
+
} | {
|
|
27
|
+
type: 'object';
|
|
28
|
+
properties: Record<string, ContractSchemaProperty>;
|
|
29
|
+
additionalProperties: false | ContractSchemaNode;
|
|
30
|
+
} | {
|
|
31
|
+
type: 'union';
|
|
32
|
+
variants: ContractSchemaNode[];
|
|
33
|
+
} | {
|
|
34
|
+
type: 'intersection';
|
|
35
|
+
variants: ContractSchemaNode[];
|
|
36
|
+
} | {
|
|
37
|
+
type: 'ref';
|
|
38
|
+
name: string;
|
|
39
|
+
};
|
|
40
|
+
export type ContractSchemaProperty = {
|
|
41
|
+
required: boolean;
|
|
42
|
+
schema: ContractSchemaNode;
|
|
43
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { buildOpenApiDocument } from './build-openapi-document.mts';
|
|
2
|
+
export type { BuildOpenApiDocumentInput } from './build-openapi-document.mts';
|
|
3
|
+
export { queryParameters } from './build-openapi-query.mts';
|
|
4
|
+
export { buildOperationRequestBody } from './build-openapi-request.mts';
|
|
5
|
+
export { buildOperationResponse } from './build-openapi-response.mts';
|
|
6
|
+
export { createComponentRegistry, sanitizeComponentName } from './component-registry.mts';
|
|
7
|
+
export type { ComponentRegistry } from './component-registry.mts';
|
|
8
|
+
export { canonicalContractSchema, hashContractSchema } from './contract-schema-canonical.mts';
|
|
9
|
+
export { nodeToOpenApi } from './contract-schema-to-openapi.mts';
|
|
10
|
+
export type { OpenApiConverterContext } from './contract-schema-to-openapi.mts';
|
|
11
|
+
export type { ContractSchema, ContractSchemaNode, ContractSchemaProperty, } from './contract-schema-types.mts';
|
|
12
|
+
export { responseStatusCodesForContract, routeShape, type QueryOperationContract, type RegisteredRoute, type RequestContract, type ResponseContract, } from './operation-types.mts';
|
|
13
|
+
export { registerOpenApiErrorResponse } from './openapi-error-response.mts';
|
|
14
|
+
export { canonicalTemplatesByShape, catalogResponse, operationId, pathParameters, } from './openapi-route-helpers.mts';
|
|
15
|
+
export type { OpenApiDocument, OpenApiOperation, OpenApiParameter, OpenApiRequestBody, OpenApiResponse, OpenApiSchema, } from './openapi-types.mts';
|
|
16
|
+
export type { OpenApiQueryContract, OpenApiQueryParameter } from './query-types.mts';
|
|
17
|
+
export { objectNode, requiredProperty } from './schema-node-builders.mts';
|
|
18
|
+
export { writeOpenApi } from './write-openapi.mts';
|
|
19
|
+
export type { WriteOpenApiOptions } from './write-openapi.mts';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { buildOpenApiDocument } from './build-openapi-document.mjs';
|
|
2
|
+
export { queryParameters } from './build-openapi-query.mjs';
|
|
3
|
+
export { buildOperationRequestBody } from './build-openapi-request.mjs';
|
|
4
|
+
export { buildOperationResponse } from './build-openapi-response.mjs';
|
|
5
|
+
export { createComponentRegistry, sanitizeComponentName } from './component-registry.mjs';
|
|
6
|
+
export { canonicalContractSchema, hashContractSchema } from './contract-schema-canonical.mjs';
|
|
7
|
+
export { nodeToOpenApi } from './contract-schema-to-openapi.mjs';
|
|
8
|
+
export { responseStatusCodesForContract, routeShape, } from './operation-types.mjs';
|
|
9
|
+
export { registerOpenApiErrorResponse } from './openapi-error-response.mjs';
|
|
10
|
+
export { canonicalTemplatesByShape, catalogResponse, operationId, pathParameters, } from './openapi-route-helpers.mjs';
|
|
11
|
+
export { objectNode, requiredProperty } from './schema-node-builders.mjs';
|
|
12
|
+
export { writeOpenApi } from './write-openapi.mjs';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const ERROR_BODY_NODE = {
|
|
2
|
+
type: 'object',
|
|
3
|
+
properties: {
|
|
4
|
+
message: { required: true, schema: { type: 'string' } },
|
|
5
|
+
code: { required: false, schema: { type: 'string' } },
|
|
6
|
+
request_id: { required: false, schema: { type: 'string' } },
|
|
7
|
+
stack: { required: false, schema: { type: 'string' } },
|
|
8
|
+
},
|
|
9
|
+
additionalProperties: false,
|
|
10
|
+
};
|
|
11
|
+
export function registerOpenApiErrorResponse(registry) {
|
|
12
|
+
registry.register('openapi-error-response', { ErrorBody: ERROR_BODY_NODE });
|
|
13
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { OpenApiParameter, OpenApiResponseOrRef } from './openapi-types.mts';
|
|
2
|
+
export declare function canonicalTemplatesByShape(routeTemplates: readonly string[]): Map<string, string>;
|
|
3
|
+
export declare function pathParameters(routeTemplate: string): OpenApiParameter[];
|
|
4
|
+
export declare function operationId(method: string, routeTemplate: string): string;
|
|
5
|
+
export declare function catalogResponse(kind: 'ordinary' | 'sse' | 'error-only' | 'fixed-no-content', fixedStatus?: number): {
|
|
6
|
+
responses: Record<number, OpenApiResponseOrRef>;
|
|
7
|
+
unavailable: boolean;
|
|
8
|
+
unavailableReason?: string;
|
|
9
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { STATUS_CODES } from 'node:http';
|
|
2
|
+
import { routeShape } from './operation-types.mjs';
|
|
3
|
+
export function canonicalTemplatesByShape(routeTemplates) {
|
|
4
|
+
const canonical = new Map();
|
|
5
|
+
for (const routeTemplate of routeTemplates) {
|
|
6
|
+
const shape = routeShape(routeTemplate);
|
|
7
|
+
const existing = canonical.get(shape);
|
|
8
|
+
if (!existing || routeTemplate.localeCompare(existing) < 0)
|
|
9
|
+
canonical.set(shape, routeTemplate);
|
|
10
|
+
}
|
|
11
|
+
return canonical;
|
|
12
|
+
}
|
|
13
|
+
export function pathParameters(routeTemplate) {
|
|
14
|
+
return [...routeTemplate.matchAll(/:(\w+)/g)].map((match) => ({
|
|
15
|
+
name: match[1],
|
|
16
|
+
in: 'path',
|
|
17
|
+
required: true,
|
|
18
|
+
schema: { type: 'string' },
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
export function operationId(method, routeTemplate) {
|
|
22
|
+
const path = routeTemplate.replace(/^\//, '').replace(/[/:]+/g, '_');
|
|
23
|
+
return `${method.toLowerCase()}_${path}`;
|
|
24
|
+
}
|
|
25
|
+
export function catalogResponse(kind, fixedStatus) {
|
|
26
|
+
if (kind === 'fixed-no-content') {
|
|
27
|
+
if (fixedStatus === undefined)
|
|
28
|
+
throw new Error('fixed-no-content route requires a status');
|
|
29
|
+
return {
|
|
30
|
+
responses: {
|
|
31
|
+
[fixedStatus]: { description: STATUS_CODES[fixedStatus] ?? 'Response' },
|
|
32
|
+
},
|
|
33
|
+
unavailable: false,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (kind === 'error-only') {
|
|
37
|
+
const reason = 'registered route has no success response';
|
|
38
|
+
return {
|
|
39
|
+
responses: { 405: { $ref: '#/components/responses/Error' } },
|
|
40
|
+
unavailable: true,
|
|
41
|
+
unavailableReason: reason,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const reason = kind === 'sse'
|
|
45
|
+
? 'SSE event payload sequence is not statically extracted'
|
|
46
|
+
: 'registered route has no statically recognized response emission';
|
|
47
|
+
return {
|
|
48
|
+
responses: kind === 'sse'
|
|
49
|
+
? {
|
|
50
|
+
200: {
|
|
51
|
+
description: 'OK',
|
|
52
|
+
content: { 'text/event-stream': { schema: {} } },
|
|
53
|
+
'x-schema-unavailable': true,
|
|
54
|
+
'x-schema-unavailable-reason': reason,
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
: {},
|
|
58
|
+
unavailable: true,
|
|
59
|
+
unavailableReason: reason,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export type OpenApiSchema = {
|
|
2
|
+
$ref?: string;
|
|
3
|
+
type?: 'null' | 'boolean' | 'integer' | 'number' | 'string' | 'array' | 'object';
|
|
4
|
+
format?: 'uri' | 'uuid';
|
|
5
|
+
const?: boolean | number | string;
|
|
6
|
+
enum?: (boolean | number | string)[];
|
|
7
|
+
items?: OpenApiSchema | false;
|
|
8
|
+
prefixItems?: OpenApiSchema[];
|
|
9
|
+
minItems?: number;
|
|
10
|
+
maxItems?: number;
|
|
11
|
+
minimum?: number;
|
|
12
|
+
maximum?: number;
|
|
13
|
+
pattern?: string;
|
|
14
|
+
default?: boolean | number | string;
|
|
15
|
+
properties?: Record<string, OpenApiSchema>;
|
|
16
|
+
required?: string[];
|
|
17
|
+
additionalProperties?: false | OpenApiSchema;
|
|
18
|
+
anyOf?: OpenApiSchema[];
|
|
19
|
+
allOf?: OpenApiSchema[];
|
|
20
|
+
};
|
|
21
|
+
export type OpenApiParameter = {
|
|
22
|
+
name: string;
|
|
23
|
+
in: 'path';
|
|
24
|
+
required: true;
|
|
25
|
+
schema: {
|
|
26
|
+
type: 'string';
|
|
27
|
+
};
|
|
28
|
+
} | {
|
|
29
|
+
name: string;
|
|
30
|
+
in: 'query';
|
|
31
|
+
required: false;
|
|
32
|
+
description?: string;
|
|
33
|
+
style?: 'form';
|
|
34
|
+
explode?: false;
|
|
35
|
+
schema: OpenApiSchema;
|
|
36
|
+
};
|
|
37
|
+
export type OpenApiResponse = {
|
|
38
|
+
description: string;
|
|
39
|
+
content?: Record<string, {
|
|
40
|
+
schema: OpenApiSchema;
|
|
41
|
+
}>;
|
|
42
|
+
'x-schema-unavailable'?: true;
|
|
43
|
+
'x-schema-unavailable-reason'?: string;
|
|
44
|
+
};
|
|
45
|
+
export type OpenApiResponseOrRef = OpenApiResponse | {
|
|
46
|
+
$ref: string;
|
|
47
|
+
};
|
|
48
|
+
export type OpenApiRequestBody = {
|
|
49
|
+
content: {
|
|
50
|
+
'application/json': {
|
|
51
|
+
schema: OpenApiSchema;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
'x-request-schema-unavailable'?: true;
|
|
55
|
+
'x-request-schema-unavailable-reason'?: string;
|
|
56
|
+
};
|
|
57
|
+
export type OpenApiOperation = {
|
|
58
|
+
operationId: string;
|
|
59
|
+
parameters?: OpenApiParameter[];
|
|
60
|
+
requestBody?: OpenApiRequestBody;
|
|
61
|
+
responses: Record<string, OpenApiResponseOrRef>;
|
|
62
|
+
'x-schema-unavailable'?: true;
|
|
63
|
+
'x-schema-unavailable-reason'?: string;
|
|
64
|
+
};
|
|
65
|
+
export type OpenApiDocument = {
|
|
66
|
+
openapi: '3.1.0';
|
|
67
|
+
info: {
|
|
68
|
+
title: string;
|
|
69
|
+
version: string;
|
|
70
|
+
};
|
|
71
|
+
paths: Record<string, Record<string, OpenApiOperation>>;
|
|
72
|
+
components: {
|
|
73
|
+
schemas: Record<string, OpenApiSchema>;
|
|
74
|
+
responses: {
|
|
75
|
+
Error: OpenApiResponse;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
'x-unavailable-routes': string[];
|
|
79
|
+
'x-unavailable-request-routes': string[];
|
|
80
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ContractSchema } from './contract-schema-types.mts';
|
|
2
|
+
import type { OpenApiQueryContract } from './query-types.mts';
|
|
3
|
+
export type ResponseContract = {
|
|
4
|
+
source: string;
|
|
5
|
+
schema: ContractSchema;
|
|
6
|
+
method: string;
|
|
7
|
+
routeTemplate: string;
|
|
8
|
+
statusCodes?: readonly [number, ...number[]];
|
|
9
|
+
statusKnowledge?: 'default' | 'explicit' | 'unknown';
|
|
10
|
+
bodyKind?: 'content' | 'none';
|
|
11
|
+
mediaType?: string;
|
|
12
|
+
mediaTypeKnowledge?: 'known' | 'none' | 'unknown';
|
|
13
|
+
unavailableReason?: string;
|
|
14
|
+
};
|
|
15
|
+
export type RequestContract = {
|
|
16
|
+
source: string;
|
|
17
|
+
schema: ContractSchema;
|
|
18
|
+
method: string;
|
|
19
|
+
routeTemplate: string;
|
|
20
|
+
unavailableReason?: string;
|
|
21
|
+
};
|
|
22
|
+
export type QueryOperationContract = {
|
|
23
|
+
method: string;
|
|
24
|
+
routeTemplate: string;
|
|
25
|
+
parameters: OpenApiQueryContract;
|
|
26
|
+
};
|
|
27
|
+
export type RegisteredRoute = {
|
|
28
|
+
method: string;
|
|
29
|
+
routeTemplate: string;
|
|
30
|
+
kind: 'ordinary' | 'sse' | 'error-only' | 'fixed-no-content';
|
|
31
|
+
fixedStatus?: number;
|
|
32
|
+
source: string;
|
|
33
|
+
};
|
|
34
|
+
export declare function responseStatusCodesForContract(contract: ResponseContract): number[];
|
|
35
|
+
export declare function routeShape(routeTemplate: string): string;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function responseStatusCodesForContract(contract) {
|
|
2
|
+
if (contract.statusKnowledge === 'unknown')
|
|
3
|
+
return [];
|
|
4
|
+
if (contract.statusCodes)
|
|
5
|
+
return [...new Set(contract.statusCodes)].toSorted((a, b) => a - b);
|
|
6
|
+
const bodyKind = contract.bodyKind ?? (contract.schema.root.type === 'null' ? 'none' : 'content');
|
|
7
|
+
return [bodyKind === 'none' ? 204 : 200];
|
|
8
|
+
}
|
|
9
|
+
export function routeShape(routeTemplate) {
|
|
10
|
+
return routeTemplate.replace(/:[^/]+/g, ':');
|
|
11
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type OpenApiQueryParameter = {
|
|
2
|
+
kind: 'string';
|
|
3
|
+
format?: 'uuid' | 'uri';
|
|
4
|
+
description?: string;
|
|
5
|
+
} | {
|
|
6
|
+
kind: 'uuid-or-uri';
|
|
7
|
+
description?: string;
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'boolean';
|
|
10
|
+
description?: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'nullable-boolean';
|
|
13
|
+
description?: string;
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'number';
|
|
16
|
+
description?: string;
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'integer';
|
|
19
|
+
minimum: number;
|
|
20
|
+
maximum: number;
|
|
21
|
+
default?: number;
|
|
22
|
+
description?: string;
|
|
23
|
+
} | {
|
|
24
|
+
kind: 'enum';
|
|
25
|
+
values: readonly string[];
|
|
26
|
+
default?: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
} | {
|
|
29
|
+
kind: 'csv-array';
|
|
30
|
+
items: OpenApiQueryParameter;
|
|
31
|
+
style: 'form';
|
|
32
|
+
explode: false;
|
|
33
|
+
description?: string;
|
|
34
|
+
};
|
|
35
|
+
export type OpenApiQueryContract = Readonly<Record<string, OpenApiQueryParameter>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { ContractSchemaNode, ContractSchemaProperty } from './contract-schema-types.mts';
|
|
2
|
+
export declare function objectNode(properties?: Record<string, ContractSchemaProperty>): ContractSchemaNode;
|
|
3
|
+
export declare function requiredProperty(schema: ContractSchemaNode, required?: boolean): ContractSchemaProperty;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { OpenApiDocument } from './openapi-types.mts';
|
|
2
|
+
export type WriteOpenApiOptions = {
|
|
3
|
+
path: string;
|
|
4
|
+
document: OpenApiDocument;
|
|
5
|
+
check?: boolean;
|
|
6
|
+
format?: (path: string, raw: string) => Promise<string>;
|
|
7
|
+
stringify?: (value: unknown) => string;
|
|
8
|
+
};
|
|
9
|
+
export declare function writeOpenApi(options: WriteOpenApiOptions): Promise<void>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
function defaultStringify(value) {
|
|
4
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
5
|
+
}
|
|
6
|
+
export async function writeOpenApi(options) {
|
|
7
|
+
const stringify = options.stringify ?? defaultStringify;
|
|
8
|
+
const format = options.format ?? (async (_path, raw) => raw);
|
|
9
|
+
const content = await format(options.path, stringify(options.document));
|
|
10
|
+
if (options.check) {
|
|
11
|
+
const actual = await readFile(options.path, 'utf8').catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error));
|
|
12
|
+
if (actual !== content) {
|
|
13
|
+
throw new Error(`${options.path} is stale. Regenerate the OpenAPI document and commit it.`);
|
|
14
|
+
}
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
await mkdir(dirname(options.path), { recursive: true });
|
|
18
|
+
await writeFile(options.path, content);
|
|
19
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SchemaCatalog } from './catalog-queries.mts';
|
|
2
|
+
import type { SchemaGrowthMaps, SchemaSnapshot } from './types.mts';
|
|
3
|
+
export { buildTableSnapshots } from './table-snapshot.mts';
|
|
4
|
+
/**
|
|
5
|
+
* Transforms raw pg_catalog rows into the committed, name-keyed schema snapshot object. Pure —
|
|
6
|
+
* no I/O. Table-specific catalog validation and structured facts live in table-snapshot.mts;
|
|
7
|
+
* this module owns the remaining schema-wide object groups and final deterministic assembly.
|
|
8
|
+
*/
|
|
9
|
+
export declare function buildSchemaSnapshot(catalog: SchemaCatalog, growth: SchemaGrowthMaps): SchemaSnapshot;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { groupBy, keyed } from './snapshot-build-utils.mjs';
|
|
2
|
+
import { buildTableSnapshots } from './table-snapshot.mjs';
|
|
3
|
+
export { buildTableSnapshots } from './table-snapshot.mjs';
|
|
4
|
+
/**
|
|
5
|
+
* Transforms raw pg_catalog rows into the committed, name-keyed schema snapshot object. Pure —
|
|
6
|
+
* no I/O. Table-specific catalog validation and structured facts live in table-snapshot.mts;
|
|
7
|
+
* this module owns the remaining schema-wide object groups and final deterministic assembly.
|
|
8
|
+
*/
|
|
9
|
+
export function buildSchemaSnapshot(catalog, growth) {
|
|
10
|
+
const tables = buildTableSnapshots({
|
|
11
|
+
catalog,
|
|
12
|
+
columnsByTable: groupBy(catalog.columns, (row) => row.table_name),
|
|
13
|
+
constraintsByTable: groupBy(catalog.constraints, (row) => row.table_name),
|
|
14
|
+
indexesByTable: groupBy(catalog.indexes, (row) => row.table_name),
|
|
15
|
+
triggersByTable: groupBy(catalog.triggers, (row) => row.table_name),
|
|
16
|
+
growth,
|
|
17
|
+
});
|
|
18
|
+
const enumValuesByName = groupBy(catalog.enums, (row) => row.enum_name);
|
|
19
|
+
const views = {};
|
|
20
|
+
for (const view of catalog.views) {
|
|
21
|
+
views[view.view_name] = {
|
|
22
|
+
definition: view.definition,
|
|
23
|
+
comment: view.comment,
|
|
24
|
+
materialized: view.materialized,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const enums = {};
|
|
28
|
+
for (const [enumName, values] of enumValuesByName) {
|
|
29
|
+
enums[enumName] = { values: values.map((value) => value.value) };
|
|
30
|
+
}
|
|
31
|
+
const extensions = {};
|
|
32
|
+
for (const extension of catalog.extensions) {
|
|
33
|
+
extensions[extension.extension_name] = { version: extension.version };
|
|
34
|
+
}
|
|
35
|
+
const functions = {};
|
|
36
|
+
for (const fn of catalog.functions) {
|
|
37
|
+
const key = fn.identity_arguments
|
|
38
|
+
? `${fn.function_name}(${fn.identity_arguments})`
|
|
39
|
+
: fn.function_name;
|
|
40
|
+
functions[key] = { definition: fn.definition };
|
|
41
|
+
}
|
|
42
|
+
const policies = {};
|
|
43
|
+
for (const policy of catalog.policies) {
|
|
44
|
+
policies[`${policy.table_name}.${policy.policy_name}`] = {
|
|
45
|
+
table: policy.table_name,
|
|
46
|
+
command: policy.command,
|
|
47
|
+
pgRoles: policy.pg_roles,
|
|
48
|
+
using: policy.using_expression,
|
|
49
|
+
withCheck: policy.with_check_expression,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
formatVersion: 2,
|
|
54
|
+
tables,
|
|
55
|
+
views: keyed(Object.entries(views)),
|
|
56
|
+
enums: keyed(Object.entries(enums)),
|
|
57
|
+
extensions: keyed(Object.entries(extensions)),
|
|
58
|
+
functions: keyed(Object.entries(functions)),
|
|
59
|
+
policies: keyed(Object.entries(policies)),
|
|
60
|
+
};
|
|
61
|
+
}
|