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.
Files changed (111) hide show
  1. package/README.md +21 -1
  2. package/dist/cli/commands/gha-artifacts-cleanup.d.mts +6 -0
  3. package/dist/cli/commands/gha-artifacts-cleanup.mjs +50 -0
  4. package/dist/cli/commands/http-origin.d.mts +2 -0
  5. package/dist/cli/commands/http-origin.mjs +14 -0
  6. package/dist/cli/index.mjs +11 -0
  7. package/dist/cli/parse-gha-artifacts-cleanup.d.mts +15 -0
  8. package/dist/cli/parse-gha-artifacts-cleanup.mjs +74 -0
  9. package/dist/cli/parse.d.mts +7 -2
  10. package/dist/cli/parse.mjs +36 -0
  11. package/dist/cli/usage.d.mts +1 -1
  12. package/dist/cli/usage.mjs +9 -0
  13. package/dist/gha-artifacts-cleanup/classify.d.mts +14 -0
  14. package/dist/gha-artifacts-cleanup/classify.mjs +32 -0
  15. package/dist/gha-artifacts-cleanup/commands.d.mts +23 -0
  16. package/dist/gha-artifacts-cleanup/commands.mjs +55 -0
  17. package/dist/gha-artifacts-cleanup/github.d.mts +16 -0
  18. package/dist/gha-artifacts-cleanup/github.mjs +100 -0
  19. package/dist/gha-artifacts-cleanup/index.d.mts +8 -0
  20. package/dist/gha-artifacts-cleanup/index.mjs +4 -0
  21. package/dist/gha-artifacts-cleanup/plan.d.mts +17 -0
  22. package/dist/gha-artifacts-cleanup/plan.mjs +40 -0
  23. package/dist/gha-selected-files/index.d.mts +6 -0
  24. package/dist/gha-selected-files/index.mjs +38 -0
  25. package/dist/http-origin/index.d.mts +1 -0
  26. package/dist/http-origin/index.mjs +23 -0
  27. package/dist/index.d.mts +11 -2
  28. package/dist/index.mjs +7 -1
  29. package/dist/openapi-document/build-openapi-document.d.mts +11 -0
  30. package/dist/openapi-document/build-openapi-document.mjs +122 -0
  31. package/dist/openapi-document/build-openapi-query.d.mts +3 -0
  32. package/dist/openapi-document/build-openapi-query.mjs +53 -0
  33. package/dist/openapi-document/build-openapi-request.d.mts +7 -0
  34. package/dist/openapi-document/build-openapi-request.mjs +29 -0
  35. package/dist/openapi-document/build-openapi-response.d.mts +9 -0
  36. package/dist/openapi-document/build-openapi-response.mjs +111 -0
  37. package/dist/openapi-document/component-registry.d.mts +9 -0
  38. package/dist/openapi-document/component-registry.mjs +49 -0
  39. package/dist/openapi-document/contract-schema-canonical.d.mts +4 -0
  40. package/dist/openapi-document/contract-schema-canonical.mjs +23 -0
  41. package/dist/openapi-document/contract-schema-intersection-merge.d.mts +13 -0
  42. package/dist/openapi-document/contract-schema-intersection-merge.mjs +96 -0
  43. package/dist/openapi-document/contract-schema-to-openapi.d.mts +7 -0
  44. package/dist/openapi-document/contract-schema-to-openapi.mjs +71 -0
  45. package/dist/openapi-document/contract-schema-types.d.mts +43 -0
  46. package/dist/openapi-document/contract-schema-types.mjs +1 -0
  47. package/dist/openapi-document/index.d.mts +19 -0
  48. package/dist/openapi-document/index.mjs +12 -0
  49. package/dist/openapi-document/openapi-error-response.d.mts +2 -0
  50. package/dist/openapi-document/openapi-error-response.mjs +13 -0
  51. package/dist/openapi-document/openapi-route-helpers.d.mts +9 -0
  52. package/dist/openapi-document/openapi-route-helpers.mjs +61 -0
  53. package/dist/openapi-document/openapi-types.d.mts +80 -0
  54. package/dist/openapi-document/openapi-types.mjs +1 -0
  55. package/dist/openapi-document/operation-types.d.mts +35 -0
  56. package/dist/openapi-document/operation-types.mjs +11 -0
  57. package/dist/openapi-document/query-types.d.mts +35 -0
  58. package/dist/openapi-document/query-types.mjs +1 -0
  59. package/dist/openapi-document/schema-node-builders.d.mts +3 -0
  60. package/dist/openapi-document/schema-node-builders.mjs +6 -0
  61. package/dist/openapi-document/write-openapi.d.mts +9 -0
  62. package/dist/openapi-document/write-openapi.mjs +19 -0
  63. package/dist/pg-schema-snapshot/build-snapshot.d.mts +9 -0
  64. package/dist/pg-schema-snapshot/build-snapshot.mjs +61 -0
  65. package/dist/pg-schema-snapshot/catalog-objects.d.mts +34 -0
  66. package/dist/pg-schema-snapshot/catalog-objects.mjs +64 -0
  67. package/dist/pg-schema-snapshot/catalog-queries.d.mts +20 -0
  68. package/dist/pg-schema-snapshot/catalog-queries.mjs +31 -0
  69. package/dist/pg-schema-snapshot/catalog-shared.d.mts +3 -0
  70. package/dist/pg-schema-snapshot/catalog-shared.mjs +19 -0
  71. package/dist/pg-schema-snapshot/catalog-table-constraints.d.mts +14 -0
  72. package/dist/pg-schema-snapshot/catalog-table-constraints.mjs +54 -0
  73. package/dist/pg-schema-snapshot/catalog-table-indexes.d.mts +30 -0
  74. package/dist/pg-schema-snapshot/catalog-table-indexes.mjs +81 -0
  75. package/dist/pg-schema-snapshot/catalog-tables.d.mts +22 -0
  76. package/dist/pg-schema-snapshot/catalog-tables.mjs +48 -0
  77. package/dist/pg-schema-snapshot/file-safety.d.mts +4 -0
  78. package/dist/pg-schema-snapshot/file-safety.mjs +71 -0
  79. package/dist/pg-schema-snapshot/generate.d.mts +18 -0
  80. package/dist/pg-schema-snapshot/generate.mjs +95 -0
  81. package/dist/pg-schema-snapshot/index-rename-detect.d.mts +40 -0
  82. package/dist/pg-schema-snapshot/index-rename-detect.mjs +71 -0
  83. package/dist/pg-schema-snapshot/index.d.mts +10 -0
  84. package/dist/pg-schema-snapshot/index.mjs +6 -0
  85. package/dist/pg-schema-snapshot/markdown-files.d.mts +1 -0
  86. package/dist/pg-schema-snapshot/markdown-files.mjs +26 -0
  87. package/dist/pg-schema-snapshot/render-markdown.d.mts +8 -0
  88. package/dist/pg-schema-snapshot/render-markdown.mjs +113 -0
  89. package/dist/pg-schema-snapshot/render-tables.d.mts +2 -0
  90. package/dist/pg-schema-snapshot/render-tables.mjs +55 -0
  91. package/dist/pg-schema-snapshot/snapshot-build-utils.d.mts +2 -0
  92. package/dist/pg-schema-snapshot/snapshot-build-utils.mjs +15 -0
  93. package/dist/pg-schema-snapshot/snapshot.test-helpers.d.mts +11 -0
  94. package/dist/pg-schema-snapshot/snapshot.test-helpers.mjs +101 -0
  95. package/dist/pg-schema-snapshot/table-snapshot-columns.d.mts +3 -0
  96. package/dist/pg-schema-snapshot/table-snapshot-columns.mjs +33 -0
  97. package/dist/pg-schema-snapshot/table-snapshot-constraints.d.mts +7 -0
  98. package/dist/pg-schema-snapshot/table-snapshot-constraints.mjs +55 -0
  99. package/dist/pg-schema-snapshot/table-snapshot.d.mts +10 -0
  100. package/dist/pg-schema-snapshot/table-snapshot.mjs +99 -0
  101. package/dist/pg-schema-snapshot/types.d.mts +103 -0
  102. package/dist/pg-schema-snapshot/types.mjs +1 -0
  103. package/dist/process-line-buffer/index.d.mts +7 -0
  104. package/dist/process-line-buffer/index.mjs +28 -0
  105. package/dist/shared-context/{fake-git.test-helpers.d.mts → fake-git.d.mts} +1 -0
  106. package/dist/shared-context/{fake-git.test-helpers.mjs → fake-git.mjs} +4 -1
  107. package/dist/shared-context/index.d.mts +2 -0
  108. package/dist/shared-context/index.mjs +1 -0
  109. package/package.json +37 -1
  110. package/scripts/gha/diagnose-port-collision.sh +232 -0
  111. package/scripts/gha/prepare-trivy-db.sh +32 -0
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Removes the index name from a `pg_get_indexdef()` definition, leaving a byte-exact "shape key"
3
+ * for comparing two index definitions on identity (uniqueness, table, `ON ONLY`, access method,
4
+ * key columns, `INCLUDE`, opclasses, `WHERE`) while ignoring only the name.
5
+ *
6
+ * Locates the name by offset rather than by regex: it finds the first ` INDEX ` (present exactly
7
+ * once, since nothing before it in `CREATE [UNIQUE] INDEX ...` can contain that substring), then
8
+ * confirms the token immediately after is exactly `name` or `"name"`. Throws if neither matches —
9
+ * a caller that silently skipped an unrecognized definition would mask a real collision instead of
10
+ * reporting one.
11
+ */
12
+ export function indexShapeKey(name, definition) {
13
+ const marker = ' INDEX ';
14
+ const markerIndex = definition.indexOf(marker);
15
+ if (markerIndex === -1) {
16
+ throw new Error(`indexShapeKey: could not find ${JSON.stringify(marker)} in index definition: ${definition}`);
17
+ }
18
+ const nameStart = markerIndex + marker.length;
19
+ const quotedName = `"${name}"`;
20
+ const bareNameEnd = nameStart + name.length;
21
+ const nameEnd = definition.startsWith(quotedName, nameStart)
22
+ ? nameStart + quotedName.length
23
+ : definition.startsWith(name, nameStart) &&
24
+ (bareNameEnd === definition.length || !/[a-zA-Z0-9_"]/.test(definition[bareNameEnd]))
25
+ ? bareNameEnd
26
+ : null;
27
+ if (nameEnd === null) {
28
+ throw new Error(`indexShapeKey: index name ${JSON.stringify(name)} does not match the token following ${JSON.stringify(marker)} in: ${definition}`);
29
+ }
30
+ return `${definition.slice(0, nameStart)}<name>${definition.slice(nameEnd)}`;
31
+ }
32
+ /**
33
+ * Detects indexes present at `base` whose name is gone at `head` but whose physical shape
34
+ * reappears under a different name — the cross-revision rename case that a single-revision
35
+ * shape scan structurally cannot see.
36
+ *
37
+ * Scope is renames only: a base index whose name simply disappears with no shape match at head
38
+ * (dropped outright, not renamed) is not reported.
39
+ *
40
+ * Index names are schema-unique in PostgreSQL, so a flat name set (rather than a per-table one)
41
+ * is sufficient to know whether a name survived into head.
42
+ */
43
+ export function detectRenamedIndexes({ base, head, }) {
44
+ const headNames = new Set();
45
+ const headNamesByShape = new Map();
46
+ for (const table of Object.values(head.tables)) {
47
+ for (const [name, index] of Object.entries(table.indexes)) {
48
+ headNames.add(name);
49
+ const shapeKey = indexShapeKey(name, index.definition);
50
+ const namesForShape = headNamesByShape.get(shapeKey);
51
+ if (namesForShape)
52
+ namesForShape.push(name);
53
+ else
54
+ headNamesByShape.set(shapeKey, [name]);
55
+ }
56
+ }
57
+ const renames = [];
58
+ for (const [tableName, table] of Object.entries(base.tables)) {
59
+ for (const [retiredName, retiredIndex] of Object.entries(table.indexes)) {
60
+ if (headNames.has(retiredName))
61
+ continue;
62
+ const retiredDefinition = retiredIndex.definition;
63
+ const shapeKey = indexShapeKey(retiredName, retiredDefinition);
64
+ const renamedCandidates = headNamesByShape.get(shapeKey) ?? [];
65
+ for (const renamedTo of renamedCandidates) {
66
+ renames.push({ table: tableName, retiredName, retiredDefinition, renamedTo });
67
+ }
68
+ }
69
+ }
70
+ return renames;
71
+ }
@@ -0,0 +1,10 @@
1
+ export { buildSchemaSnapshot, buildTableSnapshots } from './build-snapshot.mts';
2
+ export { readColumns, readConstraints, readEnums, readExtensions, readFunctions, readIndexes, readPolicies, readSchemaCatalog, readTables, readTriggers, readViews, } from './catalog-queries.mts';
3
+ export type { CatalogColumnRow, CatalogConstraintRow, CatalogEnumValueRow, CatalogExtensionRow, CatalogFunctionRow, CatalogIndexRow, CatalogPolicyRow, CatalogTableRow, CatalogTriggerRow, CatalogViewRow, SchemaCatalog, } from './catalog-queries.mts';
4
+ export { generateSchemaSnapshot, stableStringify, writeSchemaSnapshot } from './generate.mts';
5
+ export { detectRenamedIndexes, indexShapeKey } from './index-rename-detect.mts';
6
+ export type { RenamedIndex, SchemaIndexRenameSnapshot } from './index-rename-detect.mts';
7
+ export { renderSchemaMarkdown } from './render-markdown.mts';
8
+ export type { SchemaMarkdownFiles } from './render-markdown.mts';
9
+ export { renderTableDocument } from './render-tables.mts';
10
+ export type { CatalogQuery, PartitionPolicy, SchemaColumnSnapshot, SchemaEnumSnapshot, SchemaExtensionSnapshot, SchemaForeignKeySnapshot, SchemaFunctionSnapshot, SchemaGrowthMaps, SchemaIndexKeySnapshot, SchemaIndexSnapshot, SchemaKeyConstraintSnapshot, SchemaPhysicalPartitionSnapshot, SchemaPolicySnapshot, SchemaSnapshot, SchemaTableSnapshot, SchemaViewSnapshot, } from './types.mts';
@@ -0,0 +1,6 @@
1
+ export { buildSchemaSnapshot, buildTableSnapshots } from './build-snapshot.mjs';
2
+ export { readColumns, readConstraints, readEnums, readExtensions, readFunctions, readIndexes, readPolicies, readSchemaCatalog, readTables, readTriggers, readViews, } from './catalog-queries.mjs';
3
+ export { generateSchemaSnapshot, stableStringify, writeSchemaSnapshot } from './generate.mjs';
4
+ export { detectRenamedIndexes, indexShapeKey } from './index-rename-detect.mjs';
5
+ export { renderSchemaMarkdown } from './render-markdown.mjs';
6
+ export { renderTableDocument } from './render-tables.mjs';
@@ -0,0 +1 @@
1
+ export declare function markdownFilesOnDisk(root: string): Promise<string[]>;
@@ -0,0 +1,26 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { lstatOrNull } from './file-safety.mjs';
4
+ function unsafeSchemaSnapshotPath(path) {
5
+ return new Error(`Unsafe generated PostgreSQL schema snapshot path: ${path}`);
6
+ }
7
+ async function collectMarkdownFiles(directory) {
8
+ const entries = await readdir(directory, { withFileTypes: true });
9
+ const paths = await Promise.all(entries.map(async (entry) => {
10
+ const path = join(directory, entry.name);
11
+ if (entry.isDirectory())
12
+ return collectMarkdownFiles(path);
13
+ return entry.isFile() || entry.isSymbolicLink() ? [path] : [];
14
+ }));
15
+ return paths.flat();
16
+ }
17
+ export async function markdownFilesOnDisk(root) {
18
+ const markdownRoot = join(root, 'markdown');
19
+ const markdownRootStats = await lstatOrNull(markdownRoot);
20
+ if (markdownRootStats === null)
21
+ return [];
22
+ if (markdownRootStats.isSymbolicLink() || !markdownRootStats.isDirectory()) {
23
+ throw unsafeSchemaSnapshotPath(markdownRoot);
24
+ }
25
+ return (await collectMarkdownFiles(markdownRoot)).toSorted((left, right) => left.localeCompare(right));
26
+ }
@@ -0,0 +1,8 @@
1
+ import type { SchemaSnapshot } from './types.mts';
2
+ export type SchemaMarkdownFiles = Map<string, string>;
3
+ /**
4
+ * Renders deterministic, focused Markdown files from the same snapshot serialized to schema.json.
5
+ * Each table receives one safe filename and exactly one column table; the index directly links all
6
+ * generated leaves.
7
+ */
8
+ export declare function renderSchemaMarkdown(snapshot: SchemaSnapshot): SchemaMarkdownFiles;
@@ -0,0 +1,113 @@
1
+ import { renderTableDocument } from './render-tables.mjs';
2
+ const SAFE_TABLE_FILENAME = /^[a-z][a-z0-9_]*$/;
3
+ function heading(level, text) {
4
+ return `${'#'.repeat(level)} ${text}`;
5
+ }
6
+ function renderViewsDocument(snapshot) {
7
+ const names = Object.keys(snapshot.views).toSorted((left, right) => left.localeCompare(right));
8
+ const lines = [heading(1, 'Views'), '', '[Schema index](README.md).'];
9
+ if (names.length === 0)
10
+ return `${[...lines, '', '_none_'].join('\n')}\n`;
11
+ for (const name of names) {
12
+ const view = snapshot.views[name];
13
+ lines.push('', heading(2, `\`${name}\`${view.materialized ? ' (materialized)' : ''}`));
14
+ if (view.comment)
15
+ lines.push('', view.comment);
16
+ lines.push('', '```sql', view.definition, '```');
17
+ }
18
+ return `${lines.join('\n')}\n`;
19
+ }
20
+ function renderEnumsDocument(snapshot) {
21
+ const names = Object.keys(snapshot.enums).toSorted((left, right) => left.localeCompare(right));
22
+ const lines = [heading(1, 'Enums'), '', '[Schema index](README.md).'];
23
+ if (names.length === 0)
24
+ return `${[...lines, '', '_none_'].join('\n')}\n`;
25
+ for (const name of names) {
26
+ lines.push('', heading(2, `\`${name}\``), ...snapshot.enums[name].values.map((value) => `- \`${value}\``));
27
+ }
28
+ return `${lines.join('\n')}\n`;
29
+ }
30
+ function renderExtensionsDocument(snapshot) {
31
+ const names = Object.keys(snapshot.extensions).toSorted((left, right) => left.localeCompare(right));
32
+ const lines = [
33
+ heading(1, 'Extensions'),
34
+ '',
35
+ '[Schema index](README.md).',
36
+ '',
37
+ '| Extension | Version |',
38
+ '| --- | --- |',
39
+ ];
40
+ lines.push(...names.map((name) => `| \`${name}\` | ${snapshot.extensions[name].version} |`));
41
+ return `${lines.join('\n')}\n`;
42
+ }
43
+ function renderFunctionSignature(definition) {
44
+ const lines = definition.split('\n');
45
+ const bodyStart = lines.findIndex((line) => line.trim().startsWith('AS $'));
46
+ return (bodyStart === -1 ? lines : lines.slice(0, bodyStart)).join('\n');
47
+ }
48
+ function renderFunctionsDocument(snapshot) {
49
+ const names = Object.keys(snapshot.functions).toSorted((left, right) => left.localeCompare(right));
50
+ const lines = [heading(1, 'Functions'), '', '[Schema index](README.md).'];
51
+ if (names.length === 0)
52
+ return `${[...lines, '', '_none_'].join('\n')}\n`;
53
+ for (const name of names) {
54
+ lines.push('', heading(2, `\`${name}\``), '', '```sql', renderFunctionSignature(snapshot.functions[name].definition), '```');
55
+ }
56
+ return `${lines.join('\n')}\n`;
57
+ }
58
+ function renderPoliciesDocument(snapshot) {
59
+ const names = Object.keys(snapshot.policies).toSorted((left, right) => left.localeCompare(right));
60
+ const lines = [heading(1, 'Row-Level Security Policies'), '', '[Schema index](README.md).'];
61
+ if (names.length === 0)
62
+ return `${[...lines, '', '_none_'].join('\n')}\n`;
63
+ for (const name of names) {
64
+ const policy = snapshot.policies[name];
65
+ lines.push('', heading(2, `\`${name}\``), `- command: \`${policy.command}\``, `- roles: ${policy.pgRoles.map((role) => `\`${role}\``).join(', ')}`, `- using: ${policy.using ? `\`${policy.using}\`` : '_none_'}`, `- with check: ${policy.withCheck ? `\`${policy.withCheck}\`` : '_none_'}`);
66
+ }
67
+ return `${lines.join('\n')}\n`;
68
+ }
69
+ function markdownTablePath(name) {
70
+ if (!SAFE_TABLE_FILENAME.test(name)) {
71
+ throw new Error(`Cannot generate a safe Markdown filename for PostgreSQL table ${JSON.stringify(name)}.`);
72
+ }
73
+ return `tables/${name}.md`;
74
+ }
75
+ function renderSchemaIndex(tableNames) {
76
+ const lines = [
77
+ '# PostgreSQL Schema Snapshot',
78
+ '',
79
+ 'Generated schema snapshot. Do not hand-edit. Full function bodies live only in the companion [`schema.json`](../schema.json).',
80
+ '',
81
+ '## Tables',
82
+ '',
83
+ ...tableNames.map((name) => `- [\`${name}\`](tables/${name}.md)`),
84
+ '',
85
+ '## Other schema objects',
86
+ '',
87
+ '- [Views](views.md)',
88
+ '- [Enums](enums.md)',
89
+ '- [Extensions](extensions.md)',
90
+ '- [Functions](functions.md)',
91
+ '- [Row-Level Security Policies](policies.md)',
92
+ ];
93
+ return `${lines.join('\n')}\n`;
94
+ }
95
+ /**
96
+ * Renders deterministic, focused Markdown files from the same snapshot serialized to schema.json.
97
+ * Each table receives one safe filename and exactly one column table; the index directly links all
98
+ * generated leaves.
99
+ */
100
+ export function renderSchemaMarkdown(snapshot) {
101
+ const tableNames = Object.keys(snapshot.tables).toSorted((left, right) => left.localeCompare(right));
102
+ const files = new Map();
103
+ files.set('README.md', renderSchemaIndex(tableNames));
104
+ for (const name of tableNames) {
105
+ files.set(markdownTablePath(name), renderTableDocument(name, snapshot.tables[name]));
106
+ }
107
+ files.set('views.md', renderViewsDocument(snapshot));
108
+ files.set('enums.md', renderEnumsDocument(snapshot));
109
+ files.set('extensions.md', renderExtensionsDocument(snapshot));
110
+ files.set('functions.md', renderFunctionsDocument(snapshot));
111
+ files.set('policies.md', renderPoliciesDocument(snapshot));
112
+ return files;
113
+ }
@@ -0,0 +1,2 @@
1
+ import type { SchemaTableSnapshot } from './types.mts';
2
+ export declare function renderTableDocument(name: string, table: SchemaTableSnapshot): string;
@@ -0,0 +1,55 @@
1
+ function heading(level, text) {
2
+ return `${'#'.repeat(level)} ${text}`;
3
+ }
4
+ function renderPartitionLine(table) {
5
+ if (!table.partition)
6
+ return `Not partitioned — growth: ${table.growth}.`;
7
+ const { strategy, key, children, retentionOwner, accessClass } = table.partition;
8
+ const retention = retentionOwner ? `retention owner \`${retentionOwner}\`` : 'no retention owner';
9
+ return `${strategy} partitioned on \`${key}\` (children: ${children}, ${retention}, access class: ${accessClass}, growth: ${table.growth}).`;
10
+ }
11
+ function oneLine(value) {
12
+ return value.replaceAll(/\r?\n/g, ' ');
13
+ }
14
+ function renderColumnRow(name, column) {
15
+ const defaultValue = column.generatedExpression ?? column.defaultExpression;
16
+ const cells = [
17
+ `\`${name}\``,
18
+ `\`${column.type}\``,
19
+ column.nullable ? 'yes' : 'no',
20
+ defaultValue ? `\`${oneLine(defaultValue)}\`` : '',
21
+ column.identity ?? '',
22
+ column.generated ?? '',
23
+ column.collation ?? '',
24
+ column.comment ? oneLine(column.comment) : '',
25
+ ];
26
+ return `| ${cells.map((cell) => cell.replaceAll('|', '\\|')).join(' | ')} |`;
27
+ }
28
+ function renderNamedDefinitionList(entries, emptyText) {
29
+ const names = Object.keys(entries).toSorted((left, right) => left.localeCompare(right));
30
+ if (names.length === 0)
31
+ return [emptyText];
32
+ return names.map((name) => {
33
+ const entry = entries[name];
34
+ return `- \`${name}\`: \`${typeof entry === 'string' ? entry : entry.definition}\``;
35
+ });
36
+ }
37
+ export function renderTableDocument(name, table) {
38
+ const lines = [
39
+ heading(1, `Table \`${name}\``),
40
+ '',
41
+ 'Generated schema snapshot. Do not hand-edit. [Schema index](../README.md).',
42
+ ];
43
+ if (table.comment)
44
+ lines.push('', table.comment);
45
+ lines.push('', renderPartitionLine(table));
46
+ const columnNames = Object.keys(table.columns).toSorted((left, right) => table.columns[left].ordinalPosition - table.columns[right].ordinalPosition);
47
+ lines.push('', '| Column | Type | Nullable | Default | Identity | Generated | Collation | Comment |', '| --- | --- | --- | --- | --- | --- | --- | --- |', ...columnNames.map((columnName) => renderColumnRow(columnName, table.columns[columnName])));
48
+ lines.push('', `**Primary key:** ${table.primaryKey ? `\`${table.primaryKey.definition}\`` : '_none_'}`);
49
+ lines.push('', '**Unique constraints:**', ...renderNamedDefinitionList(table.uniqueConstraints, '_none_'));
50
+ lines.push('', '**Check constraints:**', ...renderNamedDefinitionList(table.checkConstraints, '_none_'));
51
+ lines.push('', '**Foreign keys:**', ...renderNamedDefinitionList(table.foreignKeys, '_none_'));
52
+ lines.push('', '**Indexes:**', ...renderNamedDefinitionList(table.indexes, '_none_'));
53
+ lines.push('', '**Triggers:**', ...renderNamedDefinitionList(table.triggers, '_none_'));
54
+ return `${lines.join('\n')}\n`;
55
+ }
@@ -0,0 +1,2 @@
1
+ export declare function groupBy<Row, Key>(rows: Row[], key: (row: Row) => Key): Map<Key, Row[]>;
2
+ export declare function keyed<RecordValue>(entries: Iterable<[string, RecordValue]>): Record<string, RecordValue>;
@@ -0,0 +1,15 @@
1
+ export function groupBy(rows, key) {
2
+ const groups = new Map();
3
+ for (const row of rows) {
4
+ const groupKey = key(row);
5
+ const group = groups.get(groupKey);
6
+ if (group)
7
+ group.push(row);
8
+ else
9
+ groups.set(groupKey, [row]);
10
+ }
11
+ return groups;
12
+ }
13
+ export function keyed(entries) {
14
+ return Object.fromEntries([...entries].toSorted(([left], [right]) => left.localeCompare(right)));
15
+ }
@@ -0,0 +1,11 @@
1
+ import type { SchemaCatalog } from './catalog-queries.mts';
2
+ import type { PartitionPolicy, SchemaGrowthMaps, SchemaSnapshot, SchemaTableSnapshot } from './types.mts';
3
+ export declare function emptyCatalog(): SchemaCatalog;
4
+ export declare function catalog(overrides?: Partial<SchemaCatalog>): SchemaCatalog;
5
+ export declare function emptyGrowth(): SchemaGrowthMaps;
6
+ export declare const WIDGETS_RANGE_POLICY: PartitionPolicy;
7
+ export declare function widgetsGrowth(overrides?: Partial<SchemaGrowthMaps>): SchemaGrowthMaps;
8
+ export declare function emptySnapshot(): SchemaSnapshot;
9
+ export declare function widgetsTable(overrides?: Partial<SchemaTableSnapshot>): SchemaTableSnapshot;
10
+ export declare function widgetsCatalogTable(relkind?: 'p' | 'r'): SchemaCatalog['tables'][number];
11
+ export declare function columnRow(tableName: string, columnName: string, overrides?: Partial<SchemaCatalog['columns'][number]>): SchemaCatalog['columns'][number];
@@ -0,0 +1,101 @@
1
+ export function emptyCatalog() {
2
+ return {
3
+ tables: [],
4
+ columns: [],
5
+ constraints: [],
6
+ indexes: [],
7
+ triggers: [],
8
+ enums: [],
9
+ views: [],
10
+ extensions: [],
11
+ functions: [],
12
+ policies: [],
13
+ };
14
+ }
15
+ export function catalog(overrides = {}) {
16
+ return { ...emptyCatalog(), ...overrides };
17
+ }
18
+ export function emptyGrowth() {
19
+ return {
20
+ partitionPolicies: new Map(),
21
+ unboundedUnpartitionedTables: new Set(),
22
+ };
23
+ }
24
+ export const WIDGETS_RANGE_POLICY = {
25
+ strategy: 'RANGE',
26
+ key: 'id',
27
+ children: 'monthly',
28
+ retentionOwner: 'cleanup',
29
+ accessClass: 'retention-window',
30
+ };
31
+ export function widgetsGrowth(overrides = {}) {
32
+ return {
33
+ partitionPolicies: new Map([['widgets', WIDGETS_RANGE_POLICY]]),
34
+ unboundedUnpartitionedTables: new Set(['events']),
35
+ ...overrides,
36
+ };
37
+ }
38
+ export function emptySnapshot() {
39
+ return {
40
+ formatVersion: 2,
41
+ tables: {},
42
+ views: {},
43
+ enums: {},
44
+ extensions: {},
45
+ functions: {},
46
+ policies: {},
47
+ };
48
+ }
49
+ export function widgetsTable(overrides = {}) {
50
+ return {
51
+ columns: {
52
+ id: {
53
+ type: 'uuid',
54
+ nullable: false,
55
+ defaultExpression: null,
56
+ generatedExpression: null,
57
+ identity: null,
58
+ generated: null,
59
+ collation: null,
60
+ comment: null,
61
+ ordinalPosition: 1,
62
+ },
63
+ },
64
+ relationKind: 'table',
65
+ primaryKey: null,
66
+ uniqueConstraints: {},
67
+ checkConstraints: {},
68
+ foreignKeys: {},
69
+ indexes: {},
70
+ triggers: {},
71
+ comment: null,
72
+ physicalPartition: null,
73
+ partition: null,
74
+ growth: 'bounded',
75
+ ...overrides,
76
+ };
77
+ }
78
+ export function widgetsCatalogTable(relkind = 'r') {
79
+ return {
80
+ table_name: 'widgets',
81
+ relkind,
82
+ partition_strategy: relkind === 'p' ? 'r' : '',
83
+ partition_key: relkind === 'p' ? 'RANGE (created_at)' : null,
84
+ comment: null,
85
+ };
86
+ }
87
+ export function columnRow(tableName, columnName, overrides = {}) {
88
+ return {
89
+ table_name: tableName,
90
+ column_name: columnName,
91
+ data_type: 'uuid',
92
+ nullable: false,
93
+ default_expression: null,
94
+ identity: '',
95
+ generated: '',
96
+ collation: null,
97
+ comment: null,
98
+ ordinal_position: 1,
99
+ ...overrides,
100
+ };
101
+ }
@@ -0,0 +1,3 @@
1
+ import type { CatalogColumnRow } from './catalog-queries.mts';
2
+ import type { SchemaColumnSnapshot } from './types.mts';
3
+ export declare function buildTableColumns(rows: CatalogColumnRow[]): Record<string, SchemaColumnSnapshot>;
@@ -0,0 +1,33 @@
1
+ function toIdentity(code) {
2
+ if (code === 'a')
3
+ return 'always';
4
+ if (code === 'd')
5
+ return 'by default';
6
+ return null;
7
+ }
8
+ function toGenerated(code) {
9
+ if (code === 's')
10
+ return 'stored';
11
+ if (code === 'v')
12
+ return 'virtual';
13
+ return null;
14
+ }
15
+ export function buildTableColumns(rows) {
16
+ const columns = {};
17
+ for (const column of rows.toSorted((left, right) => left.ordinal_position - right.ordinal_position ||
18
+ left.column_name.localeCompare(right.column_name))) {
19
+ const generated = toGenerated(column.generated);
20
+ columns[column.column_name] = {
21
+ type: column.data_type,
22
+ nullable: column.nullable,
23
+ defaultExpression: generated ? null : column.default_expression,
24
+ generatedExpression: generated ? column.default_expression : null,
25
+ identity: toIdentity(column.identity),
26
+ generated,
27
+ collation: column.collation,
28
+ comment: column.comment,
29
+ ordinalPosition: column.ordinal_position,
30
+ };
31
+ }
32
+ return columns;
33
+ }
@@ -0,0 +1,7 @@
1
+ import type { CatalogConstraintRow } from './catalog-queries.mts';
2
+ import type { SchemaColumnSnapshot, SchemaTableSnapshot } from './types.mts';
3
+ export declare function buildTableConstraints({ tableName, columns, constraints, }: {
4
+ tableName: string;
5
+ columns: Record<string, SchemaColumnSnapshot>;
6
+ constraints: CatalogConstraintRow[];
7
+ }): Pick<SchemaTableSnapshot, 'primaryKey' | 'uniqueConstraints' | 'checkConstraints' | 'foreignKeys'>;
@@ -0,0 +1,55 @@
1
+ import { keyed } from './snapshot-build-utils.mjs';
2
+ function assertConstraintColumns(tableName, columns, constraint) {
3
+ for (const column of constraint.columns) {
4
+ if (!columns[column]) {
5
+ throw new Error(`Constraint "${constraint.constraint_name}" on "${tableName}" references unknown column "${column}".`);
6
+ }
7
+ }
8
+ }
9
+ function foreignKeySnapshot(tableName, columns, constraint) {
10
+ assertConstraintColumns(tableName, columns, constraint);
11
+ if (!constraint.referenced_table || !constraint.on_update || !constraint.on_delete) {
12
+ throw new Error(`Foreign key "${constraint.constraint_name}" is missing referenced-table facts.`);
13
+ }
14
+ return {
15
+ definition: constraint.definition,
16
+ columns: constraint.columns,
17
+ referencedTable: constraint.referenced_table,
18
+ referencedColumns: constraint.referenced_columns,
19
+ onUpdate: constraint.on_update,
20
+ onDelete: constraint.on_delete,
21
+ validated: constraint.validated,
22
+ };
23
+ }
24
+ export function buildTableConstraints({ tableName, columns, constraints, }) {
25
+ const primaryConstraint = constraints.find((constraint) => constraint.contype === 'p');
26
+ if (primaryConstraint)
27
+ assertConstraintColumns(tableName, columns, primaryConstraint);
28
+ const primaryKey = primaryConstraint
29
+ ? { definition: primaryConstraint.definition, columns: primaryConstraint.columns }
30
+ : null;
31
+ const uniqueConstraints = {};
32
+ const checkConstraints = {};
33
+ const foreignKeys = {};
34
+ for (const constraint of constraints) {
35
+ if (constraint.contype === 'u') {
36
+ assertConstraintColumns(tableName, columns, constraint);
37
+ uniqueConstraints[constraint.constraint_name] = {
38
+ definition: constraint.definition,
39
+ columns: constraint.columns,
40
+ };
41
+ }
42
+ if (constraint.contype === 'c') {
43
+ checkConstraints[constraint.constraint_name] = constraint.definition;
44
+ }
45
+ if (constraint.contype === 'f') {
46
+ foreignKeys[constraint.constraint_name] = foreignKeySnapshot(tableName, columns, constraint);
47
+ }
48
+ }
49
+ return {
50
+ primaryKey,
51
+ uniqueConstraints: keyed(Object.entries(uniqueConstraints)),
52
+ checkConstraints: keyed(Object.entries(checkConstraints)),
53
+ foreignKeys: keyed(Object.entries(foreignKeys)),
54
+ };
55
+ }
@@ -0,0 +1,10 @@
1
+ import type { SchemaCatalog } from './catalog-queries.mts';
2
+ import type { SchemaGrowthMaps, SchemaTableSnapshot } from './types.mts';
3
+ export declare function buildTableSnapshots({ catalog, columnsByTable, constraintsByTable, indexesByTable, triggersByTable, growth, }: {
4
+ catalog: SchemaCatalog;
5
+ columnsByTable: Map<string, SchemaCatalog['columns']>;
6
+ constraintsByTable: Map<string, SchemaCatalog['constraints']>;
7
+ indexesByTable: Map<string, SchemaCatalog['indexes']>;
8
+ triggersByTable: Map<string, SchemaCatalog['triggers']>;
9
+ growth: SchemaGrowthMaps;
10
+ }): Record<string, SchemaTableSnapshot>;
@@ -0,0 +1,99 @@
1
+ import { keyed } from './snapshot-build-utils.mjs';
2
+ import { buildTableColumns } from './table-snapshot-columns.mjs';
3
+ import { buildTableConstraints } from './table-snapshot-constraints.mjs';
4
+ function physicalPartition({ partition_key: key, partition_strategy: strategy, }) {
5
+ if (strategy === '')
6
+ return null;
7
+ if (!key)
8
+ throw new Error('A physically partitioned table must have a partition key.');
9
+ if (strategy === 'h')
10
+ return { strategy: 'hash', key };
11
+ if (strategy === 'l')
12
+ return { strategy: 'list', key };
13
+ return { strategy: 'range', key };
14
+ }
15
+ function indexSnapshot(index) {
16
+ return {
17
+ definition: index.definition,
18
+ accessMethod: index.access_method,
19
+ unique: index.unique,
20
+ primary: index.primary,
21
+ constraintBacked: index.constraint_backed,
22
+ valid: index.valid,
23
+ ready: index.ready,
24
+ keys: index.keys.map((key) => ({
25
+ column: key.column,
26
+ expression: key.expression,
27
+ opclass: key.opclass,
28
+ descending: key.descending,
29
+ nullsFirst: key.nulls_first,
30
+ })),
31
+ includedColumns: index.included_columns,
32
+ predicate: index.predicate,
33
+ };
34
+ }
35
+ function triggersSnapshot(rows) {
36
+ return keyed(rows.map((trigger) => [trigger.trigger_name, trigger.definition]));
37
+ }
38
+ function assertKnownTableRows(catalog) {
39
+ const tableNames = new Set(catalog.tables.map((table) => table.table_name));
40
+ const rows = [...catalog.columns, ...catalog.constraints, ...catalog.indexes, ...catalog.triggers];
41
+ for (const row of rows) {
42
+ if (!tableNames.has(row.table_name)) {
43
+ throw new Error(`Catalog row references unknown table "${row.table_name}".`);
44
+ }
45
+ }
46
+ }
47
+ function assertNoDuplicateNames(rows, name) {
48
+ const names = new Set();
49
+ for (const row of rows) {
50
+ const key = `${row.table_name}.${String(row[name])}`;
51
+ if (names.has(key))
52
+ throw new Error(`Catalog contains duplicate ${String(name)} "${key}".`);
53
+ names.add(key);
54
+ }
55
+ }
56
+ function assertCatalogConsistency(catalog) {
57
+ assertKnownTableRows(catalog);
58
+ assertNoDuplicateNames(catalog.columns, 'column_name');
59
+ assertNoDuplicateNames(catalog.constraints, 'constraint_name');
60
+ assertNoDuplicateNames(catalog.indexes, 'index_name');
61
+ assertNoDuplicateNames(catalog.triggers, 'trigger_name');
62
+ }
63
+ export function buildTableSnapshots({ catalog, columnsByTable, constraintsByTable, indexesByTable, triggersByTable, growth, }) {
64
+ assertCatalogConsistency(catalog);
65
+ const tables = {};
66
+ for (const table of catalog.tables.toSorted((left, right) => left.table_name.localeCompare(right.table_name))) {
67
+ const partition = growth.partitionPolicies.get(table.table_name) ?? null;
68
+ if (table.relkind === 'p' && !partition) {
69
+ throw new Error(`Table "${table.table_name}" is partitioned (relkind = 'p') but has no partitionPolicies entry. ` +
70
+ 'Every partitioned table must be classified.');
71
+ }
72
+ const physical = physicalPartition(table);
73
+ if ((table.relkind === 'p') !== (physical !== null)) {
74
+ throw new Error(`Table "${table.table_name}" has inconsistent relation kind and physical partition facts.`);
75
+ }
76
+ const columns = buildTableColumns(columnsByTable.get(table.table_name) ?? []);
77
+ tables[table.table_name] = {
78
+ relationKind: table.relkind === 'p' ? 'partitioned table' : 'table',
79
+ columns,
80
+ ...buildTableConstraints({
81
+ tableName: table.table_name,
82
+ columns,
83
+ constraints: constraintsByTable.get(table.table_name) ?? [],
84
+ }),
85
+ indexes: keyed((indexesByTable.get(table.table_name) ?? []).map((index) => [
86
+ index.index_name,
87
+ indexSnapshot(index),
88
+ ])),
89
+ triggers: triggersSnapshot(triggersByTable.get(table.table_name) ?? []),
90
+ comment: table.comment,
91
+ physicalPartition: physical,
92
+ partition,
93
+ growth: partition || growth.unboundedUnpartitionedTables.has(table.table_name)
94
+ ? 'unbounded'
95
+ : 'bounded',
96
+ };
97
+ }
98
+ return tables;
99
+ }