@kequtech/tenon-orm 0.4.2 → 0.4.4

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 (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +16 -5
  3. package/dist/check.js +4 -2
  4. package/dist/cli.js +106 -28
  5. package/dist/config/connection.d.ts +3 -0
  6. package/dist/config/connection.js +22 -0
  7. package/dist/config/init.d.ts +2 -0
  8. package/dist/config/init.js +31 -0
  9. package/dist/config/load.d.ts +7 -0
  10. package/dist/config/load.js +152 -0
  11. package/dist/config/validate.d.ts +3 -0
  12. package/dist/config/validate.js +28 -0
  13. package/dist/config.d.ts +30 -0
  14. package/dist/config.js +1 -0
  15. package/dist/connection.d.ts +8 -0
  16. package/dist/connection.js +1 -1
  17. package/dist/database.d.ts +3 -7
  18. package/dist/database.js +22 -19
  19. package/dist/describe.js +3 -1
  20. package/dist/generate.d.ts +0 -11
  21. package/dist/generate.js +1 -26
  22. package/dist/generation/output.js +2 -2
  23. package/dist/index.d.ts +3 -3
  24. package/dist/inspection/cli.d.ts +3 -4
  25. package/dist/inspection/cli.js +3 -13
  26. package/dist/inspection/connection.js +23 -19
  27. package/dist/inspection/options.d.ts +5 -7
  28. package/dist/inspection/options.js +39 -22
  29. package/dist/inspection/types.d.ts +3 -2
  30. package/dist/migrations/history.d.ts +1 -1
  31. package/dist/migrations/history.js +3 -2
  32. package/dist/types.d.ts +1 -4
  33. package/dist/validation.d.ts +2 -0
  34. package/dist/validation.js +6 -0
  35. package/docs/adoption-guide.md +9 -3
  36. package/docs/agent-guide.md +13 -16
  37. package/docs/configuration.md +211 -0
  38. package/docs/connections.md +51 -27
  39. package/docs/human-guide.md +44 -25
  40. package/docs/migrations.md +13 -6
  41. package/docs/query-checking.md +14 -11
  42. package/docs/query-inspection.md +10 -7
  43. package/docs/reference.md +72 -49
  44. package/docs/schema-types.md +35 -27
  45. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.4
4
+
5
+ - Pass the shared `TenonConfig` directly to `createTenon`, including its connection, codecs, and
6
+ `onQuery` observer. `TenonConfig` replaces the `TenonOptions` type.
7
+ - Defer pool creation and connection validation until the first query, transaction, or native-pool
8
+ access. Importing configuration and closing an unused instance need no credentials. Generated
9
+ configs use plain environment reads; remove eager credential checks from existing configs.
10
+ - Support named config exports for multiple databases and CLI selection with `--database`. A sole
11
+ config is selected automatically; ambiguous or unknown names fail without fallback. `tenon init`
12
+ generates a named `main` export, and existing default exports remain supported.
13
+ - Allow migration-file creation without database credentials while retaining each selected config's
14
+ migration, generation, and checking settings.
15
+
16
+ ## 0.4.3
17
+
18
+ - Add shared runtime/CLI configuration and offline `tenon init`. Restore `tenon generate`, share migration defaults, and require config for generation/checking/inspection.
19
+ - Remove `db.generateTypes()`. Add native baseline connection/schema defaults and fix inspection timeout overrides and migration-history lookup under custom search paths.
20
+
3
21
  ## 0.4.2
4
22
 
5
23
  - Rewrite the README and consumer guides around a complete getting-started workflow and task-based navigation.
package/README.md CHANGED
@@ -15,14 +15,14 @@ typings. The PostgreSQL driver, [pgclear](https://github.com/kequtech/pgclear),
15
15
 
16
16
  ## Query with SQL
17
17
 
18
- This example assumes a `public.users` table with integer `id` and text `email` columns:
18
+ This example assumes a `public.users` table with integer `id` and text `email` columns and a
19
+ `main` export in your [shared configuration](docs/configuration.md):
19
20
 
20
21
  ```ts
21
22
  import { createTenon, sql } from '@kequtech/tenon-orm';
23
+ import { main } from './tenon.config.ts';
22
24
 
23
- const connection = process.env.DATABASE_URL;
24
- if (!connection) throw new Error('DATABASE_URL is required');
25
- const db = createTenon({ connection });
25
+ const db = createTenon(main);
26
26
 
27
27
  try {
28
28
  const email = 'reader@example.test';
@@ -74,7 +74,7 @@ version. For work in an existing project, start with the [agent guide](docs/agen
74
74
  - **SQL defines the operation.** Use PostgreSQL joins, aggregates, constraints, and upserts directly.
75
75
  Reuse queries with ordinary functions; use fragments when parts of the SQL need to vary.
76
76
  - **TypeScript describes the values.** Declare query projections with `sql<Row>`. Optionally generate
77
- table read/insert/update types with `db.generateTypes({ out: 'src/generated-schema.d.ts' })`.
77
+ table read/insert/update types with `tenon generate`.
78
78
  - **Transactions define what commits together.** Use `db.transaction(async (tx) => { ... })` and run
79
79
  every participating query through `tx`.
80
80
  - **Write guards check affected rows.** `{ affectedRows: { exact: 1 } }` rolls back a write unless
@@ -95,6 +95,16 @@ temporal values also default to strings, and JSON/array inputs use explicit enco
95
95
  `rowCount` is a number or null. See [value mappings](docs/schema-types.md#value-mappings) before
96
96
  choosing application types.
97
97
 
98
+ ## Share configuration with the CLI
99
+
100
+ Run `tenon init` to create `tenon.config.ts`. Put connection settings and codecs in its `main` export,
101
+ then reuse that object with `createTenon(main)`. Initialization stays lazy, so builds that only import
102
+ the database instance need no credentials.
103
+
104
+ The CLI uses the same config for migrations, `tenon generate`, freshness checks, `tenon check`, and
105
+ `tenon describe query.sql`. For multiple databases, add named exports and select one with `--database`.
106
+ See [shared configuration](docs/configuration.md) for the complete setup.
107
+
98
108
  ## Documentation
99
109
 
100
110
  Start with [Getting started](docs/human-guide.md) for a complete table-to-query example. Then choose
@@ -102,6 +112,7 @@ the guide for your task:
102
112
 
103
113
  | Guide | What it covers |
104
114
  | ---------------------------------------------------------- | ---------------------------------------------------------------------------- |
115
+ | [Shared configuration](docs/configuration.md) | Runtime/CLI connection settings, codecs, command defaults, and overrides |
105
116
  | [Writing queries](docs/sql.md) | Parameters, reusable functions, joins, aggregates, pagination, and fragments |
106
117
  | [Writing data](docs/mutations.md) | Inserts, patches, defaults, upserts, JSON, arrays, and batches |
107
118
  | [Schema types and values](docs/schema-types.md) | Generation, read/write types, codecs, and domain validation |
package/dist/check.js CHANGED
@@ -17,7 +17,10 @@ export async function checkQueries(options) {
17
17
  if (!['connection', 'target', 'project', 'session', 'timeouts', 'codecs', 'readTypes'].includes(name))
18
18
  throw new Error(`Unknown check option: ${name}`);
19
19
  }
20
- const project = loadProject(options);
20
+ const codecs = options.codecs ??
21
+ (typeof options.connection === 'object' ? options.connection?.codecs : undefined) ??
22
+ defaultCodecs;
23
+ const project = loadProject({ ...options, codecs });
21
24
  const libraries = librarySymbols(project);
22
25
  const diagnostics = [...project.diagnostics];
23
26
  const queries = project.selected.flatMap((source) => new SourceAnalysis(project, libraries).discover(source));
@@ -52,7 +55,6 @@ export async function checkQueries(options) {
52
55
  return { query, variants, diagnostics: analysis.issues };
53
56
  });
54
57
  const reports = [];
55
- const codecs = options.codecs ?? defaultCodecs;
56
58
  const readTypes = { ...options.readTypes };
57
59
  for (const oid of Object.keys(readTypes)) {
58
60
  if (codecs.typescript(Number(oid)) !== undefined)
package/dist/cli.js CHANGED
@@ -1,20 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { resolve } from 'node:path';
5
+ import { defaultCodecs } from '@kequtech/pgclear';
3
6
  import { adoptBaseline, captureBaseline } from './baseline.js';
4
- import { describeFlags, inspectionSettings, runDescribe } from './inspection/cli.js';
7
+ import { describeFlags, inspectionSettings } from './inspection/cli.js';
8
+ import { loadConfig } from './config/load.js';
9
+ import { initConfig } from './config/init.js';
10
+ import { configConnection } from './config/connection.js';
11
+ import { generateDefinitions } from './generate.js';
12
+ import { writeDefinitions } from './generation/output.js';
13
+ import { validatePath } from './validation.js';
14
+ import { describeQuery } from './describe.js';
5
15
  import { migrate, migrationStatus, rollback } from './migrations.js';
6
16
  import { createMigration } from './migrations/files.js';
7
- const help = `tenon migrate|status|rollback [--directory migrations] [--namespace public]
17
+ const help = `tenon init [--config path] (create config without a database)
18
+ tenon migrate|status|rollback [--directory migrations] [--namespace public]
8
19
  tenon baseline capture|adopt [--directory migrations] [--namespace public]
9
- baseline capture: [--schema name ...] (default: whole database schema)
20
+ baseline capture: [--schema name ...] [--connection native-uri] (default: whole database schema)
10
21
  tenon new <name> [--directory migrations] (no database required)
22
+ tenon generate [--out path] [--namespace public] [--check] [--config path]
11
23
  tenon describe <query.sql> [--role name] [--search-path schemas] [--time-zone zone]
12
24
  tenon check [file.ts[:line]] [--project tsconfig.json] [--json]
25
+ generate/check/describe require tenon.config.ts, .mts, .js or .mjs, or --config path.
13
26
  describe: [--connect-timeout-ms 3000] [--statement-timeout-ms 5000] [--lock-timeout-ms 1000]
14
27
  describe emits JSON metadata; it does not execute the query or verify result annotations.
15
28
  check compares source contracts; mismatches, unchecked sites and no sites fail. Unproven types are notes.
16
29
  rollback: --steps 1
17
- Connection: DATABASE_URL
30
+ All commands accept --config path. Database commands accept --database <export> to select a named config.
31
+ Multiple database exports require --database. Migration commands fall back to DATABASE_URL only without config.
18
32
  `;
19
33
  function warn(message) {
20
34
  process.stderr.write(`Warning: ${message}\n`);
@@ -22,7 +36,14 @@ function warn(message) {
22
36
  async function runBaseline(action, options) {
23
37
  warn("Capture one shared baseline at the old ORM's final schema checkpoint. Each existing database must be at that checkpoint before adoption. Tenon does not compare schemas or remove the old ORM.");
24
38
  if (action === 'capture') {
25
- const result = await captureBaseline({ ...options, onWarning: warn });
39
+ if (typeof options.connection !== 'string') {
40
+ throw new Error('Baseline capture needs a native libpq URI. Pass --connection or use a string config connection.');
41
+ }
42
+ const result = await captureBaseline({
43
+ ...options,
44
+ connection: options.connection,
45
+ onWarning: warn,
46
+ });
26
47
  process.stdout.write(`Captured ${result.upPath}. Source history and data were not changed. Commit this file and generated definitions.\n`);
27
48
  }
28
49
  else {
@@ -36,8 +57,13 @@ async function main() {
36
57
  allowPositionals: true,
37
58
  tokens: true,
38
59
  options: {
39
- directory: { type: 'string', default: 'migrations' },
40
- namespace: { type: 'string', default: 'public' },
60
+ directory: { type: 'string' },
61
+ connection: { type: 'string' },
62
+ namespace: { type: 'string' },
63
+ config: { type: 'string' },
64
+ database: { type: 'string' },
65
+ out: { type: 'string' },
66
+ check: { type: 'boolean', default: false },
41
67
  schema: { type: 'string', multiple: true },
42
68
  steps: { type: 'string', default: '1' },
43
69
  help: { type: 'boolean', default: false },
@@ -51,22 +77,31 @@ async function main() {
51
77
  return;
52
78
  }
53
79
  const [command, argument] = positionals;
54
- if (command === 'generate') {
55
- throw new Error('The generate command has been removed. Use db.generateTypes({ out, namespace?, check? }) in an application script so generation uses your configured codecs.');
80
+ for (const name of ['out', 'directory', 'project']) {
81
+ if (values[name] !== undefined)
82
+ validatePath(values[name], `--${name}`);
56
83
  }
57
84
  // Validate explicitly supplied flags, not defaults. Silently ignoring --check
58
85
  // on migrate, for example, would turn an intended dry run into a schema change.
59
86
  const allowed = new Set(['help']);
60
- if (command !== 'describe' && command !== 'check')
87
+ if (!['describe', 'check', 'generate', 'init'].includes(command ?? ''))
61
88
  allowed.add('directory');
62
- if (command !== 'new' && command !== 'describe' && command !== 'check') {
89
+ if (!['new', 'describe', 'check', 'init'].includes(command ?? '')) {
63
90
  allowed.add('namespace');
64
91
  }
65
92
  if (command === 'rollback') {
66
93
  allowed.add('steps');
67
94
  }
95
+ if (command === 'generate') {
96
+ allowed.add('out');
97
+ allowed.add('check');
98
+ }
99
+ allowed.add('config');
100
+ if (command !== 'init')
101
+ allowed.add('database');
68
102
  if (command === 'baseline' && argument === 'capture') {
69
103
  allowed.add('schema');
104
+ allowed.add('connection');
70
105
  }
71
106
  if (command === 'check') {
72
107
  allowed.add('project');
@@ -81,11 +116,22 @@ async function main() {
81
116
  throw new Error(`--${token.name} is not supported by ${command ?? 'this command'}.`);
82
117
  }
83
118
  }
119
+ if (command === 'init') {
120
+ if (positionals.length !== 1)
121
+ throw new Error('Use init [--config path] to create configuration.');
122
+ const path = await initConfig(values.config);
123
+ process.stdout.write(`Created ${path}\nSet DATABASE_URL before running database commands.\n`);
124
+ return;
125
+ }
84
126
  if (command === 'new') {
85
127
  if (positionals.length !== 2) {
86
128
  throw new Error('Use new <name> to create a migration pair.');
87
129
  }
88
- const files = await createMigration({ name: argument, directory: values.directory });
130
+ const config = await loadConfig(values.config, values.database, false);
131
+ const files = await createMigration({
132
+ name: argument,
133
+ directory: values.directory ?? config?.migrations?.directory ?? 'migrations',
134
+ });
89
135
  process.stdout.write(`Created ${files.upPath}\nCreated ${files.downPath}\n`);
90
136
  return;
91
137
  }
@@ -94,25 +140,44 @@ async function main() {
94
140
  ? positionals.length <= 2
95
141
  : baseline || command === 'describe'
96
142
  ? positionals.length === 2
97
- : positionals.length === 1 && ['migrate', 'status', 'rollback'].includes(command ?? '');
143
+ : positionals.length === 1 &&
144
+ ['migrate', 'generate', 'status', 'rollback'].includes(command ?? '');
98
145
  if (!valid) {
99
- throw new Error('Choose new, baseline capture|adopt, migrate, describe <query.sql>, check [file.ts[:line]], status, or rollback. Use --help for options.');
100
- }
101
- const connection = process.env.DATABASE_URL;
102
- if (!connection) {
103
- throw new Error('DATABASE_URL is required.');
104
- }
105
- if (command === 'describe') {
106
- await runDescribe(argument, connection, values);
107
- return;
108
- }
109
- if (command === 'check') {
146
+ throw new Error('Choose init, new, baseline capture|adopt, migrate, generate, describe <query.sql>, check [file.ts[:line]], status, or rollback. Use --help for options.');
147
+ }
148
+ if (command === 'generate' || command === 'check' || command === 'describe') {
149
+ const config = await loadConfig(values.config, values.database);
150
+ if (command === 'generate') {
151
+ const out = values.out === undefined ? config.generate?.out : resolve(values.out);
152
+ if (out === undefined)
153
+ throw new Error('Configure generate.out or pass --out.');
154
+ const namespace = values.namespace ?? config.generate?.namespace ?? config.migrations?.namespace;
155
+ const source = await generateDefinitions({
156
+ connection: configConnection(config.connection),
157
+ onWarning: warn,
158
+ ...(namespace !== undefined && { namespace }),
159
+ });
160
+ await writeDefinitions(source, out, values.check);
161
+ process.stdout.write(values.check ? 'Generated definitions are current.\n' : `Generated ${out}\n`);
162
+ return;
163
+ }
164
+ const flags = inspectionSettings(values);
165
+ const settings = {
166
+ connection: configConnection(config.connection),
167
+ session: { ...config.session, ...flags.session },
168
+ timeouts: { ...config.timeouts, ...flags.timeouts },
169
+ };
170
+ if (command === 'describe') {
171
+ const result = await describeQuery({ ...settings, query: await readFile(argument, 'utf8') });
172
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
173
+ return;
174
+ }
110
175
  const { checkQueries } = await import('./check.js');
111
176
  const report = await checkQueries({
112
- connection,
177
+ ...settings,
178
+ ...config.check,
113
179
  ...(argument === undefined ? {} : { target: argument }),
114
180
  ...(values.project === undefined ? {} : { project: values.project }),
115
- ...inspectionSettings(values),
116
181
  });
117
182
  if (values.json)
118
183
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
@@ -125,11 +190,24 @@ async function main() {
125
190
  process.exitCode = 1;
126
191
  return;
127
192
  }
128
- const options = { connection, directory: values.directory, namespace: values.namespace };
193
+ const config = await loadConfig(values.config, values.database, false);
194
+ const capture = command === 'baseline' && argument === 'capture';
195
+ const connection = values.connection ??
196
+ (capture ? config?.baseline?.connection : undefined) ??
197
+ (config === undefined ? undefined : configConnection(config.connection)) ??
198
+ process.env.DATABASE_URL;
199
+ if (!connection)
200
+ throw new Error('DATABASE_URL is required.');
201
+ const options = {
202
+ connection: typeof connection === 'string' ? connection : { ...connection, codecs: defaultCodecs },
203
+ directory: values.directory ?? config?.migrations?.directory ?? 'migrations',
204
+ namespace: values.namespace ?? config?.migrations?.namespace ?? 'public',
205
+ };
129
206
  if (baseline) {
207
+ const schemas = values.schema ?? (capture ? config?.baseline?.schemas : undefined);
130
208
  await runBaseline(argument, {
131
209
  ...options,
132
- ...(values.schema ? { schemas: values.schema } : {}),
210
+ ...(schemas !== undefined && { schemas }),
133
211
  });
134
212
  return;
135
213
  }
@@ -0,0 +1,3 @@
1
+ import type { ConnectionOptions } from '../connection.js';
2
+ /** Resolve only when a command or application actually needs its configured database. */
3
+ export declare function configConnection(value: unknown): string | ConnectionOptions;
@@ -0,0 +1,22 @@
1
+ import { plainObject } from '../validation.js';
2
+ /** Resolve only when a command or application actually needs its configured database. */
3
+ export function configConnection(value) {
4
+ if (value === undefined) {
5
+ throw new Error('Tenon config connection is required. Check its database environment variables.');
6
+ }
7
+ if (typeof value === 'string') {
8
+ if (!value.trim())
9
+ throw new Error('Tenon config connection string must be nonempty.');
10
+ return value;
11
+ }
12
+ if (value !== null && typeof value === 'object' && ('acquire' in value || 'close' in value)) {
13
+ throw new Error('Tenon config requires connection options, not an open pool.');
14
+ }
15
+ const connection = plainObject(value, 'Tenon config connection');
16
+ // An explicitly configured URI must never silently fall back to the driver's local defaults.
17
+ if ('connectionString' in connection &&
18
+ (typeof connection.connectionString !== 'string' || !connection.connectionString.trim())) {
19
+ throw new Error('Tenon config connectionString must be nonempty. Check its database environment variables.');
20
+ }
21
+ return connection;
22
+ }
@@ -0,0 +1,2 @@
1
+ /** Scaffold one value-only config, without importing existing application code or opening a pool. */
2
+ export declare function initConfig(filename?: string): Promise<string>;
@@ -0,0 +1,31 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { basename, dirname, extname, resolve } from 'node:path';
3
+ import { validatePath } from '../validation.js';
4
+ import { configNames, findConfig } from './load.js';
5
+ /** Scaffold one value-only config, without importing existing application code or opening a pool. */
6
+ export async function initConfig(filename) {
7
+ if (filename !== undefined)
8
+ validatePath(filename, 'Config filename');
9
+ const output = resolve(filename ?? 'tenon.config.ts');
10
+ const extension = extname(output);
11
+ if (!['.ts', '.mts', '.js', '.mjs'].includes(extension)) {
12
+ throw new Error('Config filename must end in .ts, .mts, .js, or .mjs.');
13
+ }
14
+ if (filename === undefined || configNames.includes(basename(output))) {
15
+ const existing = await findConfig(dirname(output));
16
+ if (existing && (filename === undefined || dirname(existing) === dirname(output))) {
17
+ throw new Error(`Tenon configuration already exists: ${existing}. Use or edit that file.`);
18
+ }
19
+ }
20
+ const typed = extension === '.ts' || extension === '.mts';
21
+ const source = `${typed ? "import type { TenonConfig } from '@kequtech/tenon-orm';\n\n" : ''}export const main = {
22
+ connection: { connectionString: process.env.DATABASE_URL },
23
+ migrations: { directory: 'migrations', namespace: 'public' },
24
+ generate: { out: 'src/generated-schema.d.ts' },
25
+ check: { project: 'tsconfig.json' },
26
+ }${typed ? ' satisfies TenonConfig' : ''};
27
+ `;
28
+ await mkdir(dirname(output), { recursive: true });
29
+ await writeFile(output, source, { encoding: 'utf8', flag: 'wx' });
30
+ return output;
31
+ }
@@ -0,0 +1,7 @@
1
+ import type { TenonConfig } from '../config.js';
2
+ export declare const configNames: string[];
3
+ /** Find configuration without importing it, including for offline initialization. */
4
+ export declare function findConfig(directory?: string): Promise<string | undefined>;
5
+ /** Import only the selected trusted configuration module; never discover application entry points. */
6
+ export declare function loadConfig(filename?: string, database?: string): Promise<TenonConfig>;
7
+ export declare function loadConfig(filename: string | undefined, database: string | undefined, required: false): Promise<TenonConfig | undefined>;
@@ -0,0 +1,152 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { sessionOptions, timeoutOptions } from '../inspection/options.js';
5
+ import { validateName } from '../sql.js';
6
+ import { plainObject, validatePath } from '../validation.js';
7
+ import { configObject, configSection } from './validate.js';
8
+ export const configNames = [
9
+ 'tenon.config.ts',
10
+ 'tenon.config.mts',
11
+ 'tenon.config.js',
12
+ 'tenon.config.mjs',
13
+ ];
14
+ async function exists(path) {
15
+ try {
16
+ return (await stat(path)).isFile();
17
+ }
18
+ catch (error) {
19
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
20
+ return false;
21
+ throw error;
22
+ }
23
+ }
24
+ function selectDatabase(exports, database) {
25
+ const names = Object.keys(exports).filter((name) => {
26
+ const value = exports[name];
27
+ return (name === 'default' || (value !== null && typeof value === 'object' && 'connection' in value));
28
+ });
29
+ if (database !== undefined && !names.includes(database)) {
30
+ throw new Error(`Unknown database export: ${database}. Available: ${names.join(', ') || 'none'}.`);
31
+ }
32
+ if (database === undefined && names.length !== 1) {
33
+ throw new Error(names.length
34
+ ? `Multiple databases exported: ${names.join(', ')}. Select one with --database.`
35
+ : 'No database configurations exported. Export a Tenon config with a connection field.');
36
+ }
37
+ return exports[database ?? names[0]];
38
+ }
39
+ /** Find configuration without importing it, including for offline initialization. */
40
+ export async function findConfig(directory = process.cwd()) {
41
+ while (true) {
42
+ const candidates = await Promise.all(configNames.map(async (name) => {
43
+ const candidate = join(directory, name);
44
+ return (await exists(candidate)) ? candidate : undefined;
45
+ }));
46
+ const found = candidates.filter((candidate) => candidate !== undefined);
47
+ if (found.length > 1)
48
+ throw new Error(`Multiple Tenon config files in ${directory}; select one with --config.`);
49
+ if (found.length) {
50
+ return found[0];
51
+ }
52
+ const parent = dirname(directory);
53
+ if (parent === directory)
54
+ return undefined;
55
+ directory = parent;
56
+ }
57
+ }
58
+ export async function loadConfig(filename, database, required = true) {
59
+ if (filename !== undefined)
60
+ validatePath(filename, 'Config filename');
61
+ if (database !== undefined && (!database.trim() || database.includes('\0'))) {
62
+ throw new Error('--database must be a nonempty export name without NUL.');
63
+ }
64
+ const selected = filename === undefined ? await findConfig() : resolve(filename);
65
+ if (!selected && !required && database === undefined)
66
+ return undefined;
67
+ if (!selected)
68
+ throw new Error('Tenon configuration is required. Run tenon init or pass --config.');
69
+ let exports;
70
+ try {
71
+ exports = await import(pathToFileURL(selected).href);
72
+ }
73
+ catch {
74
+ // Import errors may contain credentials from environment validation or dependency messages.
75
+ throw new Error(`Could not load Tenon configuration: ${selected}. Check the module and its imports.`);
76
+ }
77
+ const config = configObject(selectDatabase(exports, database));
78
+ const root = dirname(selected);
79
+ const migrations = configSection(config.migrations === undefined ? {} : config.migrations, 'migration config', ['directory', 'namespace']);
80
+ if (migrations.directory !== undefined)
81
+ validatePath(migrations.directory, 'Migration directory');
82
+ if (migrations.namespace !== undefined)
83
+ validateName(migrations.namespace);
84
+ let generate;
85
+ if (config.generate !== undefined) {
86
+ const options = configSection(config.generate, 'generation config', ['out', 'namespace']);
87
+ validatePath(options.out, 'Generation out');
88
+ if (options.namespace !== undefined)
89
+ validateName(options.namespace);
90
+ generate = {
91
+ out: resolve(root, options.out),
92
+ ...(options.namespace !== undefined && { namespace: options.namespace }),
93
+ };
94
+ }
95
+ const check = configSection(config.check === undefined ? {} : config.check, 'checking config', [
96
+ 'project',
97
+ 'readTypes',
98
+ ]);
99
+ if (check.project !== undefined)
100
+ validatePath(check.project, 'Checking project');
101
+ if (check.readTypes !== undefined) {
102
+ const mappings = plainObject(check.readTypes, 'Checking readTypes');
103
+ for (const [oid, expression] of Object.entries(mappings)) {
104
+ if (!/^[1-9]\d*$/.test(oid) ||
105
+ Number(oid) > 0xffffffff ||
106
+ typeof expression !== 'string' ||
107
+ !expression.trim()) {
108
+ throw new Error('Checking readTypes requires PostgreSQL OIDs and nonempty type expressions.');
109
+ }
110
+ }
111
+ }
112
+ const baseline = configSection(config.baseline === undefined ? {} : config.baseline, 'baseline config', ['connection', 'schemas']);
113
+ if (baseline.connection !== undefined &&
114
+ (typeof baseline.connection !== 'string' ||
115
+ !baseline.connection.trim() ||
116
+ baseline.connection.includes('\0'))) {
117
+ throw new Error('Baseline connection must be a nonempty native libpq connection string.');
118
+ }
119
+ if (baseline.schemas !== undefined) {
120
+ if (!Array.isArray(baseline.schemas) || !baseline.schemas.length)
121
+ throw new Error('Baseline schemas must be a nonempty array.');
122
+ for (const schema of baseline.schemas)
123
+ validateName(schema);
124
+ }
125
+ const session = sessionOptions(config.session === undefined ? {} : config.session);
126
+ const timeouts = timeoutOptions(config.timeouts === undefined ? {} : config.timeouts);
127
+ return {
128
+ connection: config.connection,
129
+ ...(config.onQuery !== undefined && {
130
+ onQuery: config.onQuery,
131
+ }),
132
+ migrations: {
133
+ directory: resolve(root, migrations.directory ?? 'migrations'),
134
+ ...(migrations.namespace !== undefined && { namespace: migrations.namespace }),
135
+ },
136
+ ...(generate !== undefined && { generate }),
137
+ check: {
138
+ project: resolve(root, check.project ?? 'tsconfig.json'),
139
+ ...(check.readTypes !== undefined && {
140
+ readTypes: check.readTypes,
141
+ }),
142
+ },
143
+ ...(config.baseline !== undefined && {
144
+ baseline: {
145
+ ...(baseline.connection !== undefined && { connection: baseline.connection }),
146
+ ...(baseline.schemas !== undefined && { schemas: [...baseline.schemas] }),
147
+ },
148
+ }),
149
+ session,
150
+ timeouts,
151
+ };
152
+ }
@@ -0,0 +1,3 @@
1
+ export declare function configSection(value: unknown, label: string, allowed: readonly string[]): Record<string, unknown>;
2
+ /** Check the shared shape without resolving credentials or constructing a pool. */
3
+ export declare function configObject(value: unknown): Record<string, unknown>;
@@ -0,0 +1,28 @@
1
+ import { plainObject } from '../validation.js';
2
+ export function configSection(value, label, allowed) {
3
+ const config = plainObject(value, label);
4
+ for (const key of Object.keys(config)) {
5
+ if (!allowed.includes(key))
6
+ throw new Error(`Unknown ${label} option: ${key}`);
7
+ }
8
+ return config;
9
+ }
10
+ /** Check the shared shape without resolving credentials or constructing a pool. */
11
+ export function configObject(value) {
12
+ const config = configSection(value, 'Tenon config', [
13
+ 'connection',
14
+ 'onQuery',
15
+ 'generate',
16
+ 'migrations',
17
+ 'baseline',
18
+ 'check',
19
+ 'session',
20
+ 'timeouts',
21
+ ]);
22
+ if (!('connection' in config))
23
+ throw new Error('Tenon config must declare connection.');
24
+ if (config.onQuery !== undefined && typeof config.onQuery !== 'function') {
25
+ throw new Error('Tenon config onQuery must be a function.');
26
+ }
27
+ return config;
28
+ }
@@ -0,0 +1,30 @@
1
+ import type { Pool } from '@kequtech/pgclear';
2
+ import type { ConnectionConfig } from './connection.js';
3
+ import type { QueryEvent } from './types.js';
4
+ import type { InspectionSession, InspectionTimeouts } from './inspection/types.js';
5
+ /** Shared runtime connection values and CLI defaults. Export without opening a database. */
6
+ export interface TenonConfig {
7
+ /** Environment values may be absent during builds; database use validates them. */
8
+ readonly connection: ConnectionConfig | Pool | undefined;
9
+ /** Observe runtime queries; native driver calls and CLI tooling do not invoke this callback. */
10
+ readonly onQuery?: (event: QueryEvent) => void | Promise<void>;
11
+ readonly generate?: {
12
+ readonly out: string;
13
+ readonly namespace?: string;
14
+ };
15
+ readonly migrations?: {
16
+ readonly directory?: string;
17
+ readonly namespace?: string;
18
+ };
19
+ /** Native pg_dump connection/scope; defaults to the main connection only when it is a string. */
20
+ readonly baseline?: {
21
+ readonly connection?: string | undefined;
22
+ readonly schemas?: readonly string[];
23
+ };
24
+ readonly check?: {
25
+ readonly project?: string;
26
+ readonly readTypes?: Readonly<Record<number, string>>;
27
+ };
28
+ readonly session?: InspectionSession;
29
+ readonly timeouts?: InspectionTimeouts;
30
+ }
package/dist/config.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -1,9 +1,17 @@
1
1
  import { type Pool, type PoolOptions, type QueryResult as DriverResult, type Executor } from '@kequtech/pgclear';
2
2
  import type { UnknownRow } from './query.js';
3
3
  export type ConnectionOptions = PoolOptions;
4
+ /** Connection settings and executable credentials/codecs, without a live pool. */
5
+ export type ConnectionConfig = string | (Omit<ConnectionOptions, 'connectionString'> & {
6
+ /** May be absent during builds; operations reject an explicitly missing URI. */
7
+ readonly connectionString?: string | undefined;
8
+ readonly acquire?: never;
9
+ readonly close?: never;
10
+ });
4
11
  export type Connection = string | ConnectionOptions | Pool;
5
12
  export type QueryResult<Row extends object = UnknownRow> = DriverResult<Row>;
6
13
  export type QueryExecutor = <T>(text: string, input: readonly unknown[], readResult: (result: QueryResult) => T, guarded: boolean) => Promise<T>;
14
+ export declare function isPool(connection: unknown): connection is Pool;
7
15
  /** Constructed pools are owned; supplied public pgclear pools remain application-owned. */
8
16
  export declare function createConnection(connection: Connection): {
9
17
  pool: Pool;
@@ -1,6 +1,6 @@
1
1
  import { createPool, } from '@kequtech/pgclear';
2
2
  import { plainObject } from './validation.js';
3
- function isPool(connection) {
3
+ export function isPool(connection) {
4
4
  return (connection !== null &&
5
5
  typeof connection === 'object' &&
6
6
  'acquire' in connection &&
@@ -1,7 +1,3 @@
1
- import type { Connection } from './connection.js';
2
- import type { Database, QueryEvent } from './types.js';
3
- export interface TenonOptions {
4
- readonly connection: Connection;
5
- readonly onQuery?: (event: QueryEvent) => void | Promise<void>;
6
- }
7
- export declare function createTenon(options: TenonOptions): Database;
1
+ import type { TenonConfig } from './config.js';
2
+ import type { Database } from './types.js';
3
+ export declare function createTenon(config: TenonConfig): Database;