@kequtech/tenon-orm 0.0.0-stage → 0.1.1

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 (72) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +105 -2
  3. package/dist/affected-rows.d.ts +11 -0
  4. package/dist/affected-rows.js +30 -0
  5. package/dist/baseline.d.ts +19 -0
  6. package/dist/baseline.js +100 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +158 -0
  9. package/dist/client.d.ts +4 -0
  10. package/dist/client.js +54 -0
  11. package/dist/connection.d.ts +15 -0
  12. package/dist/connection.js +51 -0
  13. package/dist/database.d.ts +7 -0
  14. package/dist/database.js +44 -0
  15. package/dist/errors.d.ts +9 -0
  16. package/dist/errors.js +18 -0
  17. package/dist/generate.d.ts +9 -0
  18. package/dist/generate.js +34 -0
  19. package/dist/generation/catalog.d.ts +19 -0
  20. package/dist/generation/catalog.js +35 -0
  21. package/dist/generation/row-types.d.ts +2 -0
  22. package/dist/generation/row-types.js +115 -0
  23. package/dist/generation/schema-summary.d.ts +6 -0
  24. package/dist/generation/schema-summary.js +114 -0
  25. package/dist/index.d.ts +18 -0
  26. package/dist/index.js +7 -0
  27. package/dist/migrations/baseline-sql.d.ts +3 -0
  28. package/dist/migrations/baseline-sql.js +38 -0
  29. package/dist/migrations/connection.d.ts +9 -0
  30. package/dist/migrations/connection.js +65 -0
  31. package/dist/migrations/files.d.ts +21 -0
  32. package/dist/migrations/files.js +131 -0
  33. package/dist/migrations/history.d.ts +12 -0
  34. package/dist/migrations/history.js +30 -0
  35. package/dist/migrations/sql.d.ts +2 -0
  36. package/dist/migrations/sql.js +94 -0
  37. package/dist/migrations.d.ts +10 -0
  38. package/dist/migrations.js +68 -0
  39. package/dist/mutations.d.ts +11 -0
  40. package/dist/mutations.js +87 -0
  41. package/dist/query-options.d.ts +15 -0
  42. package/dist/query-options.js +35 -0
  43. package/dist/query.d.ts +38 -0
  44. package/dist/query.js +129 -0
  45. package/dist/safeql/composition.d.ts +9 -0
  46. package/dist/safeql/composition.js +159 -0
  47. package/dist/safeql/eslint.d.ts +2 -0
  48. package/dist/safeql/eslint.js +45 -0
  49. package/dist/safeql/mutations.d.ts +3 -0
  50. package/dist/safeql/mutations.js +74 -0
  51. package/dist/safeql/plugin.d.ts +3 -0
  52. package/dist/safeql/plugin.js +43 -0
  53. package/dist/safeql/static.d.ts +10 -0
  54. package/dist/safeql/static.js +59 -0
  55. package/dist/safeql.d.ts +6 -0
  56. package/dist/safeql.js +7 -0
  57. package/dist/sql.d.ts +5 -0
  58. package/dist/sql.js +28 -0
  59. package/dist/transaction-sql.d.ts +2 -0
  60. package/dist/transaction-sql.js +67 -0
  61. package/dist/transaction.d.ts +4 -0
  62. package/dist/transaction.js +75 -0
  63. package/dist/types.d.ts +28 -0
  64. package/dist/types.js +1 -0
  65. package/dist/validation.d.ts +1 -0
  66. package/dist/validation.js +10 -0
  67. package/docs/adoption-guide.md +91 -0
  68. package/docs/agent-guide.md +310 -0
  69. package/docs/human-guide.md +271 -0
  70. package/docs/mutations.md +115 -0
  71. package/docs/reference.md +568 -0
  72. package/package.json +92 -4
@@ -0,0 +1,159 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import ts from 'typescript';
4
+ import { joinSeparator, quote } from '../sql.js';
5
+ import { renderMutation } from './mutations.js';
6
+ import { CompositionError, initializer, literalString, unwrap } from './static.js';
7
+ export { CompositionError } from './static.js';
8
+ /** Recognize Tenon's declarations without claiming another library's SQL tags. */
9
+ const tenonModules = new Map();
10
+ function isTenonModule(filename) {
11
+ const cached = tenonModules.get(filename);
12
+ if (cached !== undefined)
13
+ return cached;
14
+ let matches = false;
15
+ if (['query.ts', 'query.d.ts'].includes(basename(filename)) &&
16
+ ['src', 'dist'].includes(basename(dirname(filename)))) {
17
+ try {
18
+ const manifest = JSON.parse(readFileSync(join(dirname(filename), '..', 'package.json'), 'utf8'));
19
+ matches =
20
+ typeof manifest === 'object' &&
21
+ manifest !== null &&
22
+ 'name' in manifest &&
23
+ manifest.name === '@kequtech/tenon-orm';
24
+ }
25
+ catch {
26
+ // An unrelated or unavailable declaration is not a Tenon export.
27
+ }
28
+ }
29
+ tenonModules.set(filename, matches);
30
+ return matches;
31
+ }
32
+ export function isExport(node, checker, name) {
33
+ // Call signatures retain their declaration through local aliases, object properties,
34
+ // and typeof annotations. Symbol names alone only recognize direct imports.
35
+ return checker
36
+ .getTypeAtLocation(node)
37
+ .getCallSignatures()
38
+ .some((signature) => {
39
+ const declaration = signature.declaration;
40
+ return (declaration &&
41
+ ts.isFunctionDeclaration(declaration) &&
42
+ declaration.name?.text === (name === 'sql' ? 'createQuery' : name) &&
43
+ isTenonModule(declaration.getSourceFile().fileName));
44
+ });
45
+ }
46
+ export function isFragment(type) {
47
+ return type.isUnion()
48
+ ? type.types.some(isFragment)
49
+ : type.getProperty('__tenonFragment') !== undefined;
50
+ }
51
+ /** Reconstruct only immutable fragments and literal join lists. Never execute application code. */
52
+ export function renderFragment(node, checker, seen = new Set()) {
53
+ node = unwrap(node);
54
+ if (seen.has(node))
55
+ throw new CompositionError('Circular fragment reference.');
56
+ const visited = new Set(seen).add(node);
57
+ if (ts.isIdentifier(node)) {
58
+ const value = initializer(node, checker);
59
+ if (value)
60
+ return renderFragment(value, checker, visited);
61
+ }
62
+ if (ts.isTaggedTemplateExpression(node) &&
63
+ ts.isPropertyAccessExpression(node.tag) &&
64
+ node.tag.name.text === 'fragment' &&
65
+ isExport(node.tag.expression, checker, 'sql')) {
66
+ const template = node.template;
67
+ if (ts.isNoSubstitutionTemplateLiteral(template))
68
+ return template.text;
69
+ let text = template.head.text;
70
+ for (const span of template.templateSpans) {
71
+ text += renderValue(span.expression, checker, visited) + span.literal.text;
72
+ }
73
+ return text;
74
+ }
75
+ if (ts.isCallExpression(node)) {
76
+ for (const kind of ['insertValues', 'assignments']) {
77
+ if (isExport(node.expression, checker, kind))
78
+ return renderMutation(kind, node, checker);
79
+ }
80
+ }
81
+ if (ts.isCallExpression(node) &&
82
+ ts.isPropertyAccessExpression(node.expression) &&
83
+ node.expression.name.text === 'join' &&
84
+ isExport(node.expression.expression, checker, 'sql')) {
85
+ const [items, separator] = node.arguments;
86
+ const list = items && unwrap(items);
87
+ if (!list ||
88
+ !ts.isArrayLiteralExpression(list) ||
89
+ !list.elements.length ||
90
+ !separator ||
91
+ node.arguments.length !== 2) {
92
+ throw new CompositionError('sql.join needs a nonempty literal array and a separator for static checking.');
93
+ }
94
+ const value = literalString(separator, checker);
95
+ if (value === undefined) {
96
+ throw new CompositionError('sql.join separators must be literal strings or const strings.');
97
+ }
98
+ let between;
99
+ try {
100
+ between = joinSeparator(value);
101
+ }
102
+ catch (error) {
103
+ if (!(error instanceof Error))
104
+ throw error;
105
+ throw new CompositionError(error.message);
106
+ }
107
+ return list.elements.map((item) => renderFragment(item, checker, visited)).join(between);
108
+ }
109
+ throw new CompositionError('Only constant sql.fragment templates, literal sql.join arrays, and official mutation helpers are supported. Function-generated fragments and conditional SQL structure are not analyzed.');
110
+ }
111
+ export function renderIdentifier(node, checker, seen = new Set()) {
112
+ node = unwrap(node);
113
+ if (seen.has(node))
114
+ throw new CompositionError('Circular identifier reference.');
115
+ const visited = new Set(seen).add(node);
116
+ if (ts.isIdentifier(node)) {
117
+ const value = initializer(node, checker);
118
+ if (value)
119
+ return renderIdentifier(value, checker, visited);
120
+ }
121
+ if (ts.isCallExpression(node) &&
122
+ isExport(node.expression, checker, 'identifier') &&
123
+ node.arguments.length) {
124
+ return node.arguments
125
+ .map((part) => {
126
+ const value = literalString(part, checker);
127
+ if (value === undefined) {
128
+ throw new CompositionError('Identifier names must be literal strings or const strings.');
129
+ }
130
+ try {
131
+ return quote(value);
132
+ }
133
+ catch (error) {
134
+ if (!(error instanceof Error))
135
+ throw error;
136
+ throw new CompositionError(error.message);
137
+ }
138
+ })
139
+ .join('.');
140
+ }
141
+ throw new CompositionError('The identifier is selected at runtime.');
142
+ }
143
+ export function isIdentifierType(type) {
144
+ if (type.isUnion())
145
+ return type.types.some(isIdentifierType);
146
+ const symbol = type.getSymbol();
147
+ return (symbol?.getName() === 'Identifier' &&
148
+ Boolean(symbol.declarations?.some((declaration) => isTenonModule(declaration.getSourceFile().fileName))));
149
+ }
150
+ export function renderValue(node, checker, seen = new Set()) {
151
+ const type = checker.getTypeAtLocation(node);
152
+ if (isFragment(type))
153
+ return renderFragment(node, checker, seen);
154
+ if (isIdentifierType(type))
155
+ return renderIdentifier(node, checker, seen);
156
+ // Match runtime SQL: PostgreSQL infers parameter types from SQL context, never
157
+ // from JavaScript types. SafeQL numbers these tokens, respecting quotes/comments.
158
+ return '$N';
159
+ }
@@ -0,0 +1,2 @@
1
+ import type { ESLint } from 'eslint';
2
+ export declare const eslintPlugin: ESLint.Plugin;
@@ -0,0 +1,45 @@
1
+ import ts from 'typescript';
2
+ import { CompositionError, isExport, renderValue } from './composition.js';
3
+ export const eslintPlugin = {
4
+ rules: {
5
+ 'check-composition': {
6
+ meta: {
7
+ type: 'problem',
8
+ docs: { description: 'Report Tenon queries that cannot be statically checked.' },
9
+ schema: [],
10
+ },
11
+ create(context) {
12
+ // ESLint leaves parser services untyped; require the TypeScript parser's public services.
13
+ const { program, esTreeNodeToTSNodeMap: nodes } = context.sourceCode.parserServices;
14
+ if (!program || !nodes) {
15
+ throw new Error('tenon/check-composition requires type-aware TypeScript parsing. Enable parserOptions.project or projectService.');
16
+ }
17
+ const checker = program.getTypeChecker();
18
+ return {
19
+ TaggedTemplateExpression(node) {
20
+ const template = nodes.get(node);
21
+ if (!template ||
22
+ !ts.isTaggedTemplateExpression(template) ||
23
+ !isExport(template.tag, checker, 'sql'))
24
+ return;
25
+ try {
26
+ if (node.quasi.quasis.some(({ value }) => value.raw !== value.cooked)) {
27
+ throw new CompositionError('JavaScript escapes in an outer sql template would be checked as different SQL. Use literal newlines and bound string values instead.');
28
+ }
29
+ if (ts.isTemplateExpression(template.template)) {
30
+ for (const span of template.template.templateSpans) {
31
+ renderValue(span.expression, checker);
32
+ }
33
+ }
34
+ }
35
+ catch (error) {
36
+ if (!(error instanceof CompositionError))
37
+ throw error;
38
+ context.report({ node, message: error.message });
39
+ }
40
+ },
41
+ };
42
+ },
43
+ },
44
+ },
45
+ };
@@ -0,0 +1,3 @@
1
+ import ts from 'typescript';
2
+ /** Understand the official mutation contract, never the implementation of a JavaScript helper. */
3
+ export declare function renderMutation(kind: 'insertValues' | 'assignments', node: ts.CallExpression, checker: ts.TypeChecker): string;
@@ -0,0 +1,74 @@
1
+ import ts from 'typescript';
2
+ import { assignmentLayout, insertLayout, mutationColumns } from '../mutations.js';
3
+ import { CompositionError, isUndefined, literalString, unwrap } from './static.js';
4
+ const parameter = 0;
5
+ function fields(input) {
6
+ const node = unwrap(input);
7
+ if (!ts.isObjectLiteralExpression(node)) {
8
+ throw new CompositionError('Mutation inputs need inline object literals or an explicit literal column list.');
9
+ }
10
+ const result = new Map();
11
+ for (const property of node.properties) {
12
+ if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) {
13
+ throw new CompositionError('Mutation object spreads, accessors, and methods need an explicit literal column list.');
14
+ }
15
+ const name = property.name;
16
+ if (!ts.isIdentifier(name) && !ts.isStringLiteral(name) && !ts.isNumericLiteral(name)) {
17
+ throw new CompositionError('Mutation keys must be literal names; use an explicit literal column list.');
18
+ }
19
+ if (name.text === '__proto__')
20
+ throw new CompositionError('Prototype-setting object literals are unsupported mutation inputs.');
21
+ if (result.has(name.text))
22
+ throw new CompositionError('Duplicate mutation object keys are unsupported.');
23
+ result.set(name.text, ts.isPropertyAssignment(property) ? property.initializer : property.name);
24
+ }
25
+ return result;
26
+ }
27
+ /** Understand the official mutation contract, never the implementation of a JavaScript helper. */
28
+ export function renderMutation(kind, node, checker) {
29
+ if (node.questionDotToken ||
30
+ node.arguments.some(ts.isSpreadElement) ||
31
+ node.arguments.length < 1 ||
32
+ node.arguments.length > 2) {
33
+ throw new CompositionError(`sql.${kind} requires an input and an optional literal column list.`);
34
+ }
35
+ try {
36
+ const [argument, selected] = node.arguments;
37
+ let columns;
38
+ let rows;
39
+ if (selected && !isUndefined(selected, checker)) {
40
+ const list = unwrap(selected);
41
+ if (!ts.isArrayLiteralExpression(list))
42
+ throw new CompositionError('Mutation columns must be an inline literal array.');
43
+ columns = mutationColumns(list.elements.map((item) => {
44
+ const column = literalString(item, checker);
45
+ if (column === undefined)
46
+ throw new CompositionError('Mutation columns must be literal strings or const strings.');
47
+ return column;
48
+ }));
49
+ // A fixed column contract covers any nonempty batch and every omitted/default cell.
50
+ // Check all selected columns conservatively; no runtime data or JS types become SQL.
51
+ rows = [columns.map(() => parameter)];
52
+ }
53
+ else {
54
+ const value = unwrap(argument);
55
+ const objects = kind === 'insertValues' && ts.isArrayLiteralExpression(value)
56
+ ? value.elements.map(fields)
57
+ : [fields(value)];
58
+ columns = [
59
+ ...new Set(objects.flatMap((row) => [...row].filter(([, value]) => !isUndefined(value, checker)).map(([key]) => key))),
60
+ ];
61
+ rows = objects.map((row) => columns.map((column) => {
62
+ const cell = row.get(column);
63
+ return !cell || isUndefined(cell, checker) ? undefined : parameter;
64
+ }));
65
+ }
66
+ const parts = kind === 'insertValues' ? insertLayout(columns, rows) : assignmentLayout(columns, rows[0]);
67
+ return parts.map((part) => (typeof part === 'string' ? part : '$N')).join('');
68
+ }
69
+ catch (error) {
70
+ if (!(error instanceof Error))
71
+ throw error;
72
+ throw new CompositionError(`sql.${kind}: ${error instanceof CompositionError ? error.detail : error.message}`);
73
+ }
74
+ }
@@ -0,0 +1,3 @@
1
+ import type { SafeQLPlugin } from '@ts-safeql/plugin-utils';
2
+ /** Loaded through the exported SafeQL plugin descriptor. */
3
+ export declare function factory(): SafeQLPlugin;
@@ -0,0 +1,43 @@
1
+ import ts from 'typescript';
2
+ import { CompositionError, isExport, renderValue } from './composition.js';
3
+ /** Loaded through the exported SafeQL plugin descriptor. */
4
+ export function factory() {
5
+ return {
6
+ name: 'tenon',
7
+ connectionDefaults: {
8
+ inferLiterals: false,
9
+ overrides: {
10
+ types: {
11
+ int8: 'string',
12
+ numeric: 'string',
13
+ json: 'unknown',
14
+ jsonb: 'unknown',
15
+ date: 'Date | number',
16
+ timestamp: 'Date | number',
17
+ timestamptz: 'Date | number',
18
+ },
19
+ },
20
+ },
21
+ onTarget({ node, context }) {
22
+ const template = context.parser.esTreeNodeToTSNodeMap.get(node);
23
+ if (!ts.isTaggedTemplateExpression(template) ||
24
+ !isExport(template.tag, context.checker, 'sql'))
25
+ return false;
26
+ // The companion ESLint rule reports unsupported forms. Skip their SQL analysis.
27
+ if (node.quasi.quasis.some(({ value }) => value.raw !== value.cooked))
28
+ return false;
29
+ // Defer to SafeQL's configured targets, including per-connection tag selection.
30
+ return undefined;
31
+ },
32
+ onExpression({ context }) {
33
+ try {
34
+ return renderValue(context.tsNode, context.checker);
35
+ }
36
+ catch (error) {
37
+ if (!(error instanceof CompositionError))
38
+ throw error;
39
+ return false;
40
+ }
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,10 @@
1
+ import ts from 'typescript';
2
+ export declare class CompositionError extends Error {
3
+ readonly detail: string;
4
+ readonly name = "TenonCompositionError";
5
+ constructor(detail: string);
6
+ }
7
+ export declare function unwrap(node: ts.Node): ts.Node;
8
+ export declare function initializer(node: ts.Identifier, checker: ts.TypeChecker): ts.Expression | undefined;
9
+ export declare function literalString(node: ts.Node, checker: ts.TypeChecker): string | undefined;
10
+ export declare function isUndefined(node: ts.Node, checker: ts.TypeChecker): boolean;
@@ -0,0 +1,59 @@
1
+ import ts from 'typescript';
2
+ export class CompositionError extends Error {
3
+ detail;
4
+ name = 'TenonCompositionError';
5
+ constructor(detail) {
6
+ super(`Tenon cannot statically check this composition: ${detail} Use a static query/fragment, or an explicit lint suppression with database tests for every runtime variant.`);
7
+ this.detail = detail;
8
+ }
9
+ }
10
+ export function unwrap(node) {
11
+ while (ts.isParenthesizedExpression(node) ||
12
+ ts.isAsExpression(node) ||
13
+ ts.isSatisfiesExpression(node) ||
14
+ ts.isNonNullExpression(node) ||
15
+ ts.isTypeAssertionExpression(node))
16
+ node = node.expression;
17
+ return node;
18
+ }
19
+ export function initializer(node, checker) {
20
+ let symbol = checker.getSymbolAtLocation(node);
21
+ if (symbol && symbol.flags & ts.SymbolFlags.Alias)
22
+ symbol = checker.getAliasedSymbol(symbol);
23
+ const declaration = symbol?.valueDeclaration;
24
+ return declaration &&
25
+ ts.isVariableDeclaration(declaration) &&
26
+ ts.isVariableDeclarationList(declaration.parent) &&
27
+ declaration.parent.flags & ts.NodeFlags.Const
28
+ ? declaration.initializer
29
+ : undefined;
30
+ }
31
+ /** Follow immutable const aliases only. Never inspect functions or evaluate expressions. */
32
+ function constant(node, checker) {
33
+ const seen = new Set();
34
+ node = unwrap(node);
35
+ while (ts.isIdentifier(node)) {
36
+ if (seen.has(node))
37
+ throw new CompositionError('Circular const reference.');
38
+ if (seen.size >= 1000)
39
+ throw new CompositionError('Const resolution exceeds 1000 references.');
40
+ seen.add(node);
41
+ const value = initializer(node, checker);
42
+ if (!value)
43
+ break;
44
+ node = unwrap(value);
45
+ }
46
+ return node;
47
+ }
48
+ export function literalString(node, checker) {
49
+ const value = constant(node, checker);
50
+ return ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)
51
+ ? value.text
52
+ : undefined;
53
+ }
54
+ export function isUndefined(node, checker) {
55
+ const value = constant(node, checker);
56
+ return (ts.isIdentifier(value) &&
57
+ value.text === 'undefined' &&
58
+ !checker.getSymbolAtLocation(value)?.declarations?.length);
59
+ }
@@ -0,0 +1,6 @@
1
+ /** Add to a connection's plugins in normal SafeQL configuration. */
2
+ export declare const safeqlPlugin: {
3
+ package: string;
4
+ };
5
+ /** Register as `tenon` in ESLint and enable `tenon/check-composition`. */
6
+ export { eslintPlugin } from './safeql/eslint.js';
package/dist/safeql.js ADDED
@@ -0,0 +1,7 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ /** Add to a connection's plugins in normal SafeQL configuration. */
3
+ export const safeqlPlugin = {
4
+ package: fileURLToPath(new URL('./safeql/plugin.js', import.meta.url)),
5
+ };
6
+ /** Register as `tenon` in ESLint and enable `tenon/check-composition`. */
7
+ export { eslintPlugin } from './safeql/eslint.js';
package/dist/sql.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export declare function validateName(value: string): void;
2
+ export declare function quote(name: string): string;
3
+ export declare function qualified(namespace: string, table: string): string;
4
+ /** Shared by query assembly and static checking so separators produce identical SQL. */
5
+ export declare function joinSeparator(value: unknown): string;
package/dist/sql.js ADDED
@@ -0,0 +1,28 @@
1
+ export function validateName(value) {
2
+ if (typeof value !== 'string' ||
3
+ !value ||
4
+ value.includes('\0') ||
5
+ Buffer.byteLength(value) > 63) {
6
+ throw new Error('Identifiers must contain 1–63 UTF-8 bytes and no NUL character.');
7
+ }
8
+ }
9
+ export function quote(name) {
10
+ validateName(name);
11
+ return `"${name.replaceAll('"', '""')}"`;
12
+ }
13
+ export function qualified(namespace, table) {
14
+ return `${quote(namespace)}.${quote(table)}`;
15
+ }
16
+ /** Shared by query assembly and static checking so separators produce identical SQL. */
17
+ export function joinSeparator(value) {
18
+ switch (value) {
19
+ case 'AND':
20
+ return ' AND ';
21
+ case 'OR':
22
+ return ' OR ';
23
+ case ',':
24
+ return ', ';
25
+ default:
26
+ throw new Error('sql.join separator must be "AND", "OR", or ",".');
27
+ }
28
+ }
@@ -0,0 +1,2 @@
1
+ /** Public SQL cannot take ownership away from Tenon's callback transaction. */
2
+ export declare function validateTransactionSql(sql: string, name: string): void;
@@ -0,0 +1,67 @@
1
+ /** Read only the command prefix, leaving strings, bodies and SQL syntax to pg. */
2
+ function commandPrefix(sql) {
3
+ const words = [];
4
+ let i = 0;
5
+ while (i < sql.length && words.length < 3) {
6
+ if (/\s/.test(sql[i])) {
7
+ i++;
8
+ continue;
9
+ }
10
+ if (!words.length && sql[i] === ';') {
11
+ i++;
12
+ continue;
13
+ }
14
+ if (sql.startsWith('--', i)) {
15
+ i += 2;
16
+ while (i < sql.length && sql[i] !== '\n' && sql[i] !== '\r') {
17
+ i++;
18
+ }
19
+ continue;
20
+ }
21
+ if (sql.startsWith('/*', i)) {
22
+ let depth = 1;
23
+ i += 2;
24
+ while (i < sql.length && depth) {
25
+ if (sql.startsWith('/*', i)) {
26
+ depth++;
27
+ i += 2;
28
+ }
29
+ else if (sql.startsWith('*/', i)) {
30
+ depth--;
31
+ i += 2;
32
+ }
33
+ else {
34
+ i++;
35
+ }
36
+ }
37
+ continue;
38
+ }
39
+ if (sql[i] === '(') {
40
+ words.push('(');
41
+ break;
42
+ }
43
+ const word = /^[A-Za-z_\u0080-\uFFFF][A-Za-z_0-9$\u0080-\uFFFF]*/.exec(sql.slice(i));
44
+ if (!word) {
45
+ break;
46
+ }
47
+ words.push(word[0].toUpperCase());
48
+ i += word[0].length;
49
+ }
50
+ return words;
51
+ }
52
+ /** Public SQL cannot take ownership away from Tenon's callback transaction. */
53
+ export function validateTransactionSql(sql, name) {
54
+ const [first, second, third] = commandPrefix(sql);
55
+ const rollbackTo = first === 'ROLLBACK' &&
56
+ (second === 'TO' || ((second === 'WORK' || second === 'TRANSACTION') && third === 'TO'));
57
+ if (first === 'BEGIN' ||
58
+ first === 'COMMIT' ||
59
+ first === 'END' ||
60
+ first === 'ABORT' ||
61
+ (first === 'ROLLBACK' && !rollbackTo) ||
62
+ (first === 'START' && second === 'TRANSACTION') ||
63
+ // A SQL prepared statement named transaction is not a prepared transaction.
64
+ (first === 'PREPARE' && second === 'TRANSACTION' && third !== 'AS' && third !== '(')) {
65
+ throw new Error(`${name} cannot start or end a transaction; use db.transaction() to manage transaction boundaries.`);
66
+ }
67
+ }
@@ -0,0 +1,4 @@
1
+ import type { Pool } from 'pg';
2
+ import type { QueryExecutor } from './connection.js';
3
+ /** The callback owns one connection and expires before commit or rollback begins. */
4
+ export declare function withTransaction<T>(pool: Pool, work: (query: QueryExecutor) => Promise<T>): Promise<T>;
@@ -0,0 +1,75 @@
1
+ import { executeSql, watchClient } from './connection.js';
2
+ /** The callback owns one connection and expires before commit or rollback begins. */
3
+ export async function withTransaction(pool, work) {
4
+ const client = await pool.connect();
5
+ const connection = watchClient(client);
6
+ let active = true;
7
+ let releaseError;
8
+ let savepointId = 0;
9
+ let pending = Promise.resolve();
10
+ let recoveryFailure;
11
+ const query = async (text, input, readResult, guarded) => {
12
+ if (!active)
13
+ throw new Error('The transaction has finished.');
14
+ // Queue whole operations, including savepoint cleanup. Otherwise concurrent
15
+ // calls could place another write inside a savepoint that gets rolled back.
16
+ const operation = pending.then(async () => {
17
+ connection.check();
18
+ if (recoveryFailure)
19
+ throw recoveryFailure;
20
+ if (!guarded)
21
+ return readResult(await executeSql(client, text, input));
22
+ const savepoint = `tenon_affected_rows_${++savepointId}`;
23
+ await client.query(`SAVEPOINT ${savepoint}`);
24
+ try {
25
+ const result = readResult(await executeSql(client, text, input));
26
+ await client.query(`RELEASE SAVEPOINT ${savepoint}`);
27
+ return result;
28
+ }
29
+ catch (error) {
30
+ try {
31
+ await client.query(`ROLLBACK TO SAVEPOINT ${savepoint}`);
32
+ await client.query(`RELEASE SAVEPOINT ${savepoint}`);
33
+ }
34
+ catch (rollbackError) {
35
+ // Even if application code catches this error, the outer transaction
36
+ // must not commit when the rejected write could still be present.
37
+ recoveryFailure = new AggregateError([error, rollbackError], 'Operation and savepoint recovery failed.');
38
+ throw recoveryFailure;
39
+ }
40
+ throw error;
41
+ }
42
+ });
43
+ pending = operation.then(() => { }, () => { });
44
+ return operation;
45
+ };
46
+ try {
47
+ await client.query('BEGIN');
48
+ const result = await work(query);
49
+ active = false;
50
+ await pending;
51
+ connection.check();
52
+ if (recoveryFailure)
53
+ throw recoveryFailure;
54
+ const committed = await client.query('COMMIT');
55
+ if (committed.command !== 'COMMIT') {
56
+ throw new Error('PostgreSQL rolled back the aborted transaction.');
57
+ }
58
+ return result;
59
+ }
60
+ catch (error) {
61
+ active = false;
62
+ await pending;
63
+ try {
64
+ await client.query('ROLLBACK');
65
+ }
66
+ catch (rollbackError) {
67
+ releaseError = rollbackError instanceof Error ? rollbackError : new Error('Rollback failed.');
68
+ throw new AggregateError([error, rollbackError], 'Transaction and rollback failed.');
69
+ }
70
+ throw error;
71
+ }
72
+ finally {
73
+ connection.release(releaseError);
74
+ }
75
+ }
@@ -0,0 +1,28 @@
1
+ import type { QueryResult, QueryResultRow } from 'pg';
2
+ import type { QueryOptions, QueryOutput, QueryReturnMode } from './query-options.js';
3
+ import type { SqlQuery } from './query.js';
4
+ export interface QueryClient {
5
+ query<Row extends QueryResultRow>(query: SqlQuery<Row>, options?: QueryOptions & {
6
+ readonly returns?: 'native';
7
+ }): NoInfer<Promise<QueryResult<Row>>>;
8
+ query<Row extends QueryResultRow, Mode extends QueryReturnMode>(query: SqlQuery<Row>, options: QueryOptions & {
9
+ readonly returns: Mode;
10
+ }): NoInfer<Promise<QueryOutput<Row, Mode>>>;
11
+ query<Row extends QueryResultRow>(query: SqlQuery<Row>, options: QueryOptions | undefined): NoInfer<Promise<QueryOutput<Row, QueryReturnMode>>>;
12
+ }
13
+ export type Transaction = QueryClient;
14
+ export type Database = Transaction & {
15
+ /** Await all work inside the callback. Commit on success; rollback on an escaping error. */
16
+ transaction<T>(callback: (tx: Transaction) => Promise<T>): Promise<T>;
17
+ close(): Promise<void>;
18
+ };
19
+ /** One completed public query call, including rollback/commit time for guarded calls. */
20
+ export interface QueryEvent {
21
+ readonly label?: string;
22
+ readonly durationMs: number;
23
+ readonly outcome: 'success' | 'error';
24
+ readonly command?: string;
25
+ readonly rowCount?: number | null;
26
+ /** Stable Tenon or PostgreSQL error code, when available. */
27
+ readonly errorCode?: string;
28
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export declare function plainObject(value: unknown, name: string): Record<string, unknown>;
@@ -0,0 +1,10 @@
1
+ export function plainObject(value, name) {
2
+ if (value === null || typeof value !== 'object') {
3
+ throw new Error(`${name} must be a plain object.`);
4
+ }
5
+ const prototype = Object.getPrototypeOf(value);
6
+ if (prototype !== Object.prototype && prototype !== null) {
7
+ throw new Error(`${name} must be a plain object.`);
8
+ }
9
+ return value;
10
+ }