@telorun/sql 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -14
- package/dist/sql-connection-controller.d.ts +17 -11
- package/dist/sql-connection-controller.js +40 -33
- package/dist/sql-exec-controller.d.ts +1 -1
- package/dist/sql-exec-controller.js +3 -6
- package/dist/sql-migrations-controller.d.ts +5 -0
- package/dist/sql-migrations-controller.js +23 -9
- package/dist/sql-postgres-connection-controller.d.ts +19 -0
- package/dist/sql-postgres-connection-controller.js +9 -0
- package/dist/sql-query-controller.js +3 -6
- package/dist/sql-run.d.ts +16 -0
- package/dist/sql-run.js +27 -0
- package/dist/sql-sqlite-connection-controller.d.ts +13 -0
- package/dist/sql-sqlite-connection-controller.js +6 -0
- package/package.json +9 -5
- package/src/sql-connection-controller.ts +61 -49
- package/src/sql-exec-controller.ts +4 -13
- package/src/sql-migrations-controller.ts +33 -13
- package/src/sql-postgres-connection-controller.ts +28 -0
- package/src/sql-query-controller.ts +3 -13
- package/src/sql-run.ts +49 -0
- package/src/sql-sqlite-connection-controller.ts +18 -0
package/README.md
CHANGED
|
@@ -54,20 +54,13 @@ metadata:
|
|
|
54
54
|
description: |
|
|
55
55
|
A complete feedback collection REST API — no code, pure YAML.
|
|
56
56
|
Persists entries to SQLite and serves them over HTTP.
|
|
57
|
+
imports:
|
|
58
|
+
Http: std/http-server@0.8.0
|
|
59
|
+
Sql: std/sql@0.5.1
|
|
57
60
|
targets:
|
|
58
61
|
- Migrations
|
|
59
62
|
- Server
|
|
60
63
|
---
|
|
61
|
-
kind: Telo.Import
|
|
62
|
-
metadata:
|
|
63
|
-
name: Http
|
|
64
|
-
source: std/http-server@0.5.0
|
|
65
|
-
---
|
|
66
|
-
kind: Telo.Import
|
|
67
|
-
metadata:
|
|
68
|
-
name: Sql
|
|
69
|
-
source: std/sql@0.3.0
|
|
70
|
-
---
|
|
71
64
|
# SQLite database — swap driver/host/database for PostgreSQL with zero YAML changes
|
|
72
65
|
kind: Sql.Connection
|
|
73
66
|
metadata:
|
|
@@ -127,7 +120,7 @@ routes:
|
|
|
127
120
|
minLength: 1
|
|
128
121
|
source:
|
|
129
122
|
type: string
|
|
130
|
-
required: [text]
|
|
123
|
+
required: [ text ]
|
|
131
124
|
handler:
|
|
132
125
|
kind: Sql.Exec
|
|
133
126
|
connection:
|
|
@@ -157,7 +150,7 @@ routes:
|
|
|
157
150
|
kind: Sql.Connection
|
|
158
151
|
name: Db
|
|
159
152
|
from: feedback
|
|
160
|
-
columns: [id, text, source, score, created_at]
|
|
153
|
+
columns: [ id, text, source, score, created_at ]
|
|
161
154
|
orderBy:
|
|
162
155
|
- { column: created_at, direction: desc }
|
|
163
156
|
response:
|
|
@@ -176,14 +169,14 @@ routes:
|
|
|
176
169
|
properties:
|
|
177
170
|
id:
|
|
178
171
|
type: integer
|
|
179
|
-
required: [id]
|
|
172
|
+
required: [ id ]
|
|
180
173
|
handler:
|
|
181
174
|
kind: Sql.Select
|
|
182
175
|
connection:
|
|
183
176
|
kind: Sql.Connection
|
|
184
177
|
name: Db
|
|
185
178
|
from: feedback
|
|
186
|
-
columns: [id, text, source, score, created_at]
|
|
179
|
+
columns: [ id, text, source, score, created_at ]
|
|
187
180
|
where:
|
|
188
181
|
- { column: id, op: "=", value: "${{ request.params.id }}" }
|
|
189
182
|
response:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ResourceInstance } from "@telorun/sdk";
|
|
2
2
|
import { Kysely, type QueryResult } from "kysely";
|
|
3
3
|
import type { SqlTransactionResource } from "./sql-transaction-controller.js";
|
|
4
4
|
import type { SqliteDb } from "./sqlite-driver-interface.js";
|
|
@@ -8,30 +8,36 @@ interface PoolConfig {
|
|
|
8
8
|
idleTimeoutMs?: number;
|
|
9
9
|
connectionTimeoutMs?: number;
|
|
10
10
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
export type SqlDriver = "postgres" | "sqlite";
|
|
12
|
+
/** Native bind-placeholder syntax per driver: SQLite binds anonymous `?`,
|
|
13
|
+
* PostgreSQL binds numbered `$1`, `$2`, … */
|
|
14
|
+
export type PlaceholderStyle = "qmark" | "numbered";
|
|
15
|
+
export interface SqlConnectionConfig {
|
|
16
|
+
driver: SqlDriver;
|
|
17
|
+
/** Required for `postgres`; ignored for `sqlite` (which opens via `sqlite`). */
|
|
18
|
+
connectionString?: string;
|
|
17
19
|
pool?: PoolConfig;
|
|
18
20
|
}
|
|
19
|
-
export type SqlDriver = "postgres" | "sqlite";
|
|
20
21
|
export declare class SqlConnectionResource implements ResourceInstance {
|
|
21
22
|
readonly driver: SqlDriver;
|
|
22
23
|
private readonly db;
|
|
23
24
|
private readonly sqlite?;
|
|
24
|
-
constructor(
|
|
25
|
+
constructor(config: SqlConnectionConfig, sqlite?: SqliteDb);
|
|
25
26
|
init(): Promise<void>;
|
|
26
27
|
teardown(): Promise<void>;
|
|
27
28
|
transaction<T>(cb: () => Promise<T>): Promise<T>;
|
|
28
29
|
execute<T>(sql: string, params?: unknown[], transaction?: SqlTransactionResource): Promise<QueryResult<T>>;
|
|
30
|
+
get placeholderStyle(): PlaceholderStyle;
|
|
31
|
+
/** Assemble SQL from literal fragments by interleaving driver-native
|
|
32
|
+
* placeholders, then bind `values` positionally. `fragments.length` must
|
|
33
|
+
* equal `values.length + 1`. */
|
|
34
|
+
executeTemplate<T>(fragments: string[], values: unknown[], transaction?: SqlTransactionResource): Promise<QueryResult<T>>;
|
|
35
|
+
private placeholder;
|
|
29
36
|
executeScript(sql: string): Promise<void>;
|
|
30
37
|
toRowCount(result: QueryResult<unknown>): number;
|
|
31
38
|
get kysely(): Kysely<any>;
|
|
32
39
|
snapshot(): Record<string, unknown>;
|
|
33
40
|
private resolveExecutor;
|
|
34
41
|
}
|
|
35
|
-
export declare function
|
|
36
|
-
export declare function create(resource: SqlConnectionManifest, ctx: ResourceContext): Promise<SqlConnectionResource>;
|
|
42
|
+
export declare function openSqliteDatabase(file?: string): Promise<SqliteDb>;
|
|
37
43
|
export {};
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
|
-
import { CompiledQuery, Kysely, PostgresDialect, SqliteDialect, } from "kysely";
|
|
2
|
+
import { CompiledQuery, Kysely, PostgresDialect, SqliteAdapter, SqliteDialect, } from "kysely";
|
|
3
3
|
import { Pool } from "pg";
|
|
4
4
|
import { currentTxId, deleteTx, getTx, setTx, txStorage } from "./transaction-store.js";
|
|
5
5
|
export class SqlConnectionResource {
|
|
6
6
|
driver;
|
|
7
7
|
db;
|
|
8
8
|
sqlite;
|
|
9
|
-
constructor(
|
|
10
|
-
this.driver =
|
|
9
|
+
constructor(config, sqlite) {
|
|
10
|
+
this.driver = config.driver;
|
|
11
11
|
if (this.driver === "postgres") {
|
|
12
|
-
|
|
12
|
+
if (!config.connectionString) {
|
|
13
|
+
throw new Error("Sql: postgres connection requires a connectionString");
|
|
14
|
+
}
|
|
15
|
+
const url = new URL(config.connectionString);
|
|
13
16
|
const ssl = sslFromSslmode(url.searchParams.get("sslmode"));
|
|
14
17
|
url.searchParams.delete("sslmode");
|
|
15
18
|
this.db = new Kysely({
|
|
@@ -17,10 +20,10 @@ export class SqlConnectionResource {
|
|
|
17
20
|
pool: new Pool({
|
|
18
21
|
connectionString: url.toString(),
|
|
19
22
|
ssl,
|
|
20
|
-
min:
|
|
21
|
-
max:
|
|
22
|
-
idleTimeoutMillis:
|
|
23
|
-
connectionTimeoutMillis:
|
|
23
|
+
min: config.pool?.min ?? 1,
|
|
24
|
+
max: config.pool?.max ?? 10,
|
|
25
|
+
idleTimeoutMillis: config.pool?.idleTimeoutMs,
|
|
26
|
+
connectionTimeoutMillis: config.pool?.connectionTimeoutMs,
|
|
24
27
|
}),
|
|
25
28
|
}),
|
|
26
29
|
});
|
|
@@ -31,7 +34,7 @@ export class SqlConnectionResource {
|
|
|
31
34
|
}
|
|
32
35
|
this.sqlite = sqlite;
|
|
33
36
|
this.db = new Kysely({
|
|
34
|
-
dialect: new
|
|
37
|
+
dialect: new TransactionalSqliteDialect({
|
|
35
38
|
database: this.sqlite,
|
|
36
39
|
}),
|
|
37
40
|
});
|
|
@@ -61,6 +64,22 @@ export class SqlConnectionResource {
|
|
|
61
64
|
const executor = this.resolveExecutor(transaction);
|
|
62
65
|
return executor.executeQuery(CompiledQuery.raw(sql, params));
|
|
63
66
|
}
|
|
67
|
+
get placeholderStyle() {
|
|
68
|
+
return this.driver === "postgres" ? "numbered" : "qmark";
|
|
69
|
+
}
|
|
70
|
+
/** Assemble SQL from literal fragments by interleaving driver-native
|
|
71
|
+
* placeholders, then bind `values` positionally. `fragments.length` must
|
|
72
|
+
* equal `values.length + 1`. */
|
|
73
|
+
async executeTemplate(fragments, values, transaction) {
|
|
74
|
+
let sql = fragments[0] ?? "";
|
|
75
|
+
for (let i = 1; i < fragments.length; i++) {
|
|
76
|
+
sql += this.placeholder(i) + fragments[i];
|
|
77
|
+
}
|
|
78
|
+
return this.execute(sql, values, transaction);
|
|
79
|
+
}
|
|
80
|
+
placeholder(index) {
|
|
81
|
+
return this.placeholderStyle === "numbered" ? `$${index}` : "?";
|
|
82
|
+
}
|
|
64
83
|
async executeScript(sql) {
|
|
65
84
|
if (this.driver === "sqlite") {
|
|
66
85
|
this.sqlite?.exec(sql);
|
|
@@ -94,31 +113,19 @@ export class SqlConnectionResource {
|
|
|
94
113
|
return this.db;
|
|
95
114
|
}
|
|
96
115
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
function driverFromConnectionString(connectionString) {
|
|
105
|
-
const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(connectionString)?.[1]?.toLowerCase();
|
|
106
|
-
switch (scheme) {
|
|
107
|
-
case "postgres":
|
|
108
|
-
case "postgresql":
|
|
109
|
-
return "postgres";
|
|
110
|
-
case "sqlite":
|
|
111
|
-
return "sqlite";
|
|
112
|
-
default:
|
|
113
|
-
throw new Error(`Sql.Connection: connectionString must start with a driver scheme — ` +
|
|
114
|
-
`'postgres://' or 'postgresql://' for PostgreSQL, 'sqlite:' for SQLite. ` +
|
|
115
|
-
`Got ${scheme ? `'${scheme}:'` : "a string with no scheme"}: ${JSON.stringify(connectionString)}`);
|
|
116
|
+
// Kysely's stock SQLite adapter reports `supportsTransactionalDdl = false`, so
|
|
117
|
+
// its Migrator runs migrations without a transaction. SQLite does support
|
|
118
|
+
// transactional DDL, so we flip the flag — letting the Migrator wrap the whole
|
|
119
|
+
// migration batch in a single transaction, matching PostgreSQL.
|
|
120
|
+
class TransactionalSqliteAdapter extends SqliteAdapter {
|
|
121
|
+
get supportsTransactionalDdl() {
|
|
122
|
+
return true;
|
|
116
123
|
}
|
|
117
124
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
125
|
+
class TransactionalSqliteDialect extends SqliteDialect {
|
|
126
|
+
createAdapter() {
|
|
127
|
+
return new TransactionalSqliteAdapter();
|
|
128
|
+
}
|
|
122
129
|
}
|
|
123
130
|
function sslFromSslmode(mode) {
|
|
124
131
|
switch (mode) {
|
|
@@ -138,7 +145,7 @@ function sslFromSslmode(mode) {
|
|
|
138
145
|
`Use 'disable', 'require', 'verify-ca', or 'verify-full'.`);
|
|
139
146
|
}
|
|
140
147
|
}
|
|
141
|
-
async function openSqliteDatabase(file = ":memory:") {
|
|
148
|
+
export async function openSqliteDatabase(file = ":memory:") {
|
|
142
149
|
// Auto-create the parent directory for file-backed databases. SQLite
|
|
143
150
|
// drivers fail-fast when the directory doesn't exist; mirroring `mkdir
|
|
144
151
|
// -p` here lets manifests use paths like `./tmp/foo.sqlite` without a
|
|
@@ -18,7 +18,7 @@ declare class SqlExecResource implements ResourceInstance {
|
|
|
18
18
|
private readonly manifest;
|
|
19
19
|
private readonly ctx;
|
|
20
20
|
constructor(manifest: SqlExecManifest, ctx: ResourceContext);
|
|
21
|
-
invoke(input:
|
|
21
|
+
invoke(input: unknown): Promise<SqlResult>;
|
|
22
22
|
}
|
|
23
23
|
export declare function register(): void;
|
|
24
24
|
export declare function create(resource: SqlExecManifest, ctx: ResourceContext): Promise<SqlExecResource>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveSqlConnection } from "./sql-connection-ref.js";
|
|
2
|
+
import { runSql } from "./sql-run.js";
|
|
2
3
|
class SqlExecResource {
|
|
3
4
|
manifest;
|
|
4
5
|
ctx;
|
|
@@ -9,18 +10,14 @@ class SqlExecResource {
|
|
|
9
10
|
async invoke(input) {
|
|
10
11
|
const m = this.manifest;
|
|
11
12
|
const ctx = this.ctx;
|
|
12
|
-
const expandedInput = ctx.expandValue(input, {});
|
|
13
13
|
const connection = resolveSqlConnection(m.connection, ctx) ?? m.transaction?.getConnection();
|
|
14
14
|
if (!connection) {
|
|
15
15
|
throw new Error("Sql: either 'connection' or 'transaction' must be set");
|
|
16
16
|
}
|
|
17
|
-
|
|
17
|
+
const result = await runSql(connection, m.transaction, input, ctx);
|
|
18
|
+
return { rows: result.rows, rowCount: connection.toRowCount(result) };
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
|
-
async function runExec(connection, transaction, sql, params) {
|
|
21
|
-
const result = await connection.execute(sql, params, transaction);
|
|
22
|
-
return { rows: result.rows, rowCount: connection.toRowCount(result) };
|
|
23
|
-
}
|
|
24
21
|
export function register() { }
|
|
25
22
|
export async function create(resource, ctx) {
|
|
26
23
|
return new SqlExecResource(resource, ctx);
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
2
|
import type { SqlConnectionResource } from "./sql-connection-controller.js";
|
|
3
|
+
interface MigrationEntry {
|
|
4
|
+
statement?: string;
|
|
5
|
+
statements?: string[];
|
|
6
|
+
}
|
|
3
7
|
interface SqlMigrationsManifest {
|
|
4
8
|
metadata: {
|
|
5
9
|
name: string;
|
|
6
10
|
module: string;
|
|
7
11
|
};
|
|
8
12
|
connection: SqlConnectionResource;
|
|
13
|
+
migrations?: Record<string, MigrationEntry>;
|
|
9
14
|
}
|
|
10
15
|
declare class SqlMigrationsResource implements ResourceInstance {
|
|
11
16
|
private readonly manifest;
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
import { CompiledQuery, Migrator, } from "kysely";
|
|
2
2
|
import { resolveSqlConnection } from "./sql-connection-ref.js";
|
|
3
|
+
function entryStatements(entry) {
|
|
4
|
+
return entry.statements ?? (entry.statement != null ? [entry.statement] : []);
|
|
5
|
+
}
|
|
3
6
|
class TeloMigrationProvider {
|
|
4
7
|
migrations;
|
|
5
8
|
constructor(migrations) {
|
|
6
9
|
this.migrations = migrations;
|
|
7
10
|
}
|
|
8
11
|
async getMigrations() {
|
|
9
|
-
return Object.fromEntries(this.migrations.map((
|
|
10
|
-
|
|
12
|
+
return Object.fromEntries(Object.entries(this.migrations).map(([name, statements]) => [
|
|
13
|
+
name,
|
|
11
14
|
{
|
|
15
|
+
// Each statement runs as its own prepared statement on the migration
|
|
16
|
+
// transaction's connection, so a migration may hold multiple
|
|
17
|
+
// statements while the whole batch stays a single transaction.
|
|
12
18
|
async up(db) {
|
|
13
|
-
|
|
19
|
+
for (const statement of statements) {
|
|
20
|
+
await db.executeQuery(CompiledQuery.raw(statement));
|
|
21
|
+
}
|
|
14
22
|
},
|
|
15
23
|
},
|
|
16
24
|
]));
|
|
@@ -25,17 +33,23 @@ class SqlMigrationsResource {
|
|
|
25
33
|
}
|
|
26
34
|
async run() {
|
|
27
35
|
const conn = resolveSqlConnection(this.manifest.connection, this.ctx) ?? failMissingConnection();
|
|
28
|
-
const migrations =
|
|
36
|
+
const migrations = {};
|
|
37
|
+
// Legacy: standalone `Sql.Migration` resources in the same module scope.
|
|
29
38
|
for (const [, { resource }] of this.ctx.moduleContext.resourceInstances) {
|
|
30
39
|
if (resource.kind === "Sql.Migration") {
|
|
31
40
|
const version = (resource.version ?? resource.metadata.name);
|
|
32
|
-
migrations.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
migrations[version] = [resource.sql];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Preferred: the keyed `migrations` map on this resource.
|
|
45
|
+
for (const [name, entry] of Object.entries(this.manifest.migrations ?? {})) {
|
|
46
|
+
const statements = entryStatements(entry);
|
|
47
|
+
if (statements.length === 0) {
|
|
48
|
+
throw new Error(`Sql.Migrations: migration '${name}' has no statement(s) — ` +
|
|
49
|
+
`set 'statement' or a non-empty 'statements'`);
|
|
36
50
|
}
|
|
51
|
+
migrations[name] = statements;
|
|
37
52
|
}
|
|
38
|
-
migrations.sort((a, b) => a.name.localeCompare(b.name));
|
|
39
53
|
const migrator = new Migrator({
|
|
40
54
|
db: conn.kysely,
|
|
41
55
|
provider: new TeloMigrationProvider(migrations),
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ResourceContext } from "@telorun/sdk";
|
|
2
|
+
import { SqlConnectionResource } from "./sql-connection-controller.js";
|
|
3
|
+
interface PoolConfig {
|
|
4
|
+
min?: number;
|
|
5
|
+
max?: number;
|
|
6
|
+
idleTimeoutMs?: number;
|
|
7
|
+
connectionTimeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
interface PostgresConnectionManifest {
|
|
10
|
+
metadata: {
|
|
11
|
+
name: string;
|
|
12
|
+
module: string;
|
|
13
|
+
};
|
|
14
|
+
connectionString: string;
|
|
15
|
+
pool?: PoolConfig;
|
|
16
|
+
}
|
|
17
|
+
export declare function register(): void;
|
|
18
|
+
export declare function create(resource: PostgresConnectionManifest, ctx: ResourceContext): Promise<SqlConnectionResource>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { SqlConnectionResource } from "./sql-connection-controller.js";
|
|
2
|
+
export function register() { }
|
|
3
|
+
export async function create(resource, ctx) {
|
|
4
|
+
return new SqlConnectionResource({
|
|
5
|
+
driver: "postgres",
|
|
6
|
+
connectionString: resource.connectionString,
|
|
7
|
+
pool: resource.pool,
|
|
8
|
+
});
|
|
9
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveSqlConnection } from "./sql-connection-ref.js";
|
|
2
|
+
import { runSql } from "./sql-run.js";
|
|
2
3
|
class SqlQueryResource {
|
|
3
4
|
manifest;
|
|
4
5
|
ctx;
|
|
@@ -9,18 +10,14 @@ class SqlQueryResource {
|
|
|
9
10
|
async invoke(input) {
|
|
10
11
|
const m = this.manifest;
|
|
11
12
|
const ctx = this.ctx;
|
|
12
|
-
const expandedInput = ctx.expandValue(input, {});
|
|
13
13
|
const connection = resolveConnection(m.connection, m.transaction, ctx);
|
|
14
|
-
|
|
14
|
+
const result = await runSql(connection, m.transaction, input, ctx);
|
|
15
|
+
return { rows: result.rows, rowCount: result.rows.length };
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
18
|
function resolveConnection(connection, transaction, ctx) {
|
|
18
19
|
return (resolveSqlConnection(connection, ctx) ?? transaction?.getConnection() ?? failMissingConnection());
|
|
19
20
|
}
|
|
20
|
-
async function runQuery(connection, transaction, sql, params) {
|
|
21
|
-
const result = await connection.execute(sql, params, transaction);
|
|
22
|
-
return { rows: result.rows, rowCount: result.rows.length };
|
|
23
|
-
}
|
|
24
21
|
function failMissingConnection() {
|
|
25
22
|
throw new Error("Sql: either 'connection' or 'transaction' must be set");
|
|
26
23
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ResourceContext } from "@telorun/sdk";
|
|
2
|
+
import type { QueryResult } from "kysely";
|
|
3
|
+
import type { SqlConnectionResource } from "./sql-connection-controller.js";
|
|
4
|
+
import type { SqlTransactionResource } from "./sql-transaction-controller.js";
|
|
5
|
+
/** Execute the `sql` input of a Query/Exec resource against `connection`.
|
|
6
|
+
*
|
|
7
|
+
* Two modes:
|
|
8
|
+
* - **inline (`!sql` tag):** the expanded `sql` is a parameterized template —
|
|
9
|
+
* each `${{ }}` is bound via the connection's native placeholder, never
|
|
10
|
+
* spliced into the text.
|
|
11
|
+
* - **escape hatch (`bindings` given):** `sql` is a literal string with
|
|
12
|
+
* author-written `?` / `$n` placeholders and `bindings` bound positionally.
|
|
13
|
+
*
|
|
14
|
+
* Mixing a `!sql` template with `bindings` is rejected. A plain string `sql`
|
|
15
|
+
* with neither is executed verbatim. */
|
|
16
|
+
export declare function runSql(connection: SqlConnectionResource, transaction: SqlTransactionResource | undefined, input: unknown, ctx: ResourceContext): Promise<QueryResult<Record<string, unknown>>>;
|
package/dist/sql-run.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { InvokeError, isParameterizedSql } from "@telorun/sdk";
|
|
2
|
+
/** Execute the `sql` input of a Query/Exec resource against `connection`.
|
|
3
|
+
*
|
|
4
|
+
* Two modes:
|
|
5
|
+
* - **inline (`!sql` tag):** the expanded `sql` is a parameterized template —
|
|
6
|
+
* each `${{ }}` is bound via the connection's native placeholder, never
|
|
7
|
+
* spliced into the text.
|
|
8
|
+
* - **escape hatch (`bindings` given):** `sql` is a literal string with
|
|
9
|
+
* author-written `?` / `$n` placeholders and `bindings` bound positionally.
|
|
10
|
+
*
|
|
11
|
+
* Mixing a `!sql` template with `bindings` is rejected. A plain string `sql`
|
|
12
|
+
* with neither is executed verbatim. */
|
|
13
|
+
export async function runSql(connection, transaction, input, ctx) {
|
|
14
|
+
const expanded = ctx.expandValue(input, {});
|
|
15
|
+
const sql = expanded.sql;
|
|
16
|
+
const bindings = expanded.bindings;
|
|
17
|
+
const hasBindings = bindings !== undefined && bindings !== null;
|
|
18
|
+
if (isParameterizedSql(sql)) {
|
|
19
|
+
if (hasBindings) {
|
|
20
|
+
throw new InvokeError("ERR_INVALID_INPUT", "Sql: a `!sql` template cannot be combined with an explicit `bindings` array. " +
|
|
21
|
+
"Use one or the other — `!sql` binds each inline value automatically; " +
|
|
22
|
+
"`bindings` is for hand-written ? / $n placeholders.");
|
|
23
|
+
}
|
|
24
|
+
return connection.executeTemplate(sql.fragments, sql.values, transaction);
|
|
25
|
+
}
|
|
26
|
+
return connection.execute(sql, hasBindings ? bindings : [], transaction);
|
|
27
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ResourceContext } from "@telorun/sdk";
|
|
2
|
+
import { SqlConnectionResource } from "./sql-connection-controller.js";
|
|
3
|
+
interface SqliteConnectionManifest {
|
|
4
|
+
metadata: {
|
|
5
|
+
name: string;
|
|
6
|
+
module: string;
|
|
7
|
+
};
|
|
8
|
+
/** File path, or omitted / `:memory:` for an in-memory database. */
|
|
9
|
+
file?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function register(): void;
|
|
12
|
+
export declare function create(resource: SqliteConnectionManifest, ctx: ResourceContext): Promise<SqlConnectionResource>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { openSqliteDatabase, SqlConnectionResource } from "./sql-connection-controller.js";
|
|
2
|
+
export function register() { }
|
|
3
|
+
export async function create(resource, ctx) {
|
|
4
|
+
const sqlite = await openSqliteDatabase(resource.file ?? ":memory:");
|
|
5
|
+
return new SqlConnectionResource({ driver: "sqlite" }, sqlite);
|
|
6
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/sql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Telo SQL module - SQL database resource kinds for Telo manifests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -22,9 +22,13 @@
|
|
|
22
22
|
},
|
|
23
23
|
"type": "module",
|
|
24
24
|
"exports": {
|
|
25
|
-
"./sql-connection": {
|
|
26
|
-
"bun": "./src/sql-connection-controller.ts",
|
|
27
|
-
"import": "./dist/sql-connection-controller.js"
|
|
25
|
+
"./sql-postgres-connection": {
|
|
26
|
+
"bun": "./src/sql-postgres-connection-controller.ts",
|
|
27
|
+
"import": "./dist/sql-postgres-connection-controller.js"
|
|
28
|
+
},
|
|
29
|
+
"./sql-sqlite-connection": {
|
|
30
|
+
"bun": "./src/sql-sqlite-connection-controller.ts",
|
|
31
|
+
"import": "./dist/sql-sqlite-connection-controller.js"
|
|
28
32
|
},
|
|
29
33
|
"./sql-query": {
|
|
30
34
|
"bun": "./src/sql-query-controller.ts",
|
|
@@ -70,7 +74,7 @@
|
|
|
70
74
|
"@types/node": "^20.0.0",
|
|
71
75
|
"@types/pg": "^8.0.0",
|
|
72
76
|
"typescript": "^5.0.0",
|
|
73
|
-
"@telorun/sdk": "0.
|
|
77
|
+
"@telorun/sdk": "0.21.0"
|
|
74
78
|
},
|
|
75
79
|
"peerDependencies": {
|
|
76
80
|
"@telorun/sdk": "*"
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ResourceInstance } from "@telorun/sdk";
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
3
|
import {
|
|
4
4
|
CompiledQuery,
|
|
5
5
|
Kysely,
|
|
6
6
|
PostgresDialect,
|
|
7
|
+
SqliteAdapter,
|
|
7
8
|
SqliteDialect,
|
|
8
9
|
type QueryResult,
|
|
9
10
|
type Transaction,
|
|
@@ -20,24 +21,32 @@ interface PoolConfig {
|
|
|
20
21
|
connectionTimeoutMs?: number;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
export type SqlDriver = "postgres" | "sqlite";
|
|
25
|
+
|
|
26
|
+
/** Native bind-placeholder syntax per driver: SQLite binds anonymous `?`,
|
|
27
|
+
* PostgreSQL binds numbered `$1`, `$2`, … */
|
|
28
|
+
export type PlaceholderStyle = "qmark" | "numbered";
|
|
29
|
+
|
|
30
|
+
export interface SqlConnectionConfig {
|
|
31
|
+
driver: SqlDriver;
|
|
32
|
+
/** Required for `postgres`; ignored for `sqlite` (which opens via `sqlite`). */
|
|
33
|
+
connectionString?: string;
|
|
26
34
|
pool?: PoolConfig;
|
|
27
35
|
}
|
|
28
36
|
|
|
29
|
-
export type SqlDriver = "postgres" | "sqlite";
|
|
30
|
-
|
|
31
37
|
export class SqlConnectionResource implements ResourceInstance {
|
|
32
38
|
readonly driver: SqlDriver;
|
|
33
39
|
private readonly db: Kysely<any>;
|
|
34
40
|
private readonly sqlite?: SqliteDb;
|
|
35
41
|
|
|
36
|
-
constructor(
|
|
37
|
-
this.driver =
|
|
42
|
+
constructor(config: SqlConnectionConfig, sqlite?: SqliteDb) {
|
|
43
|
+
this.driver = config.driver;
|
|
38
44
|
|
|
39
45
|
if (this.driver === "postgres") {
|
|
40
|
-
|
|
46
|
+
if (!config.connectionString) {
|
|
47
|
+
throw new Error("Sql: postgres connection requires a connectionString");
|
|
48
|
+
}
|
|
49
|
+
const url = new URL(config.connectionString);
|
|
41
50
|
const ssl = sslFromSslmode(url.searchParams.get("sslmode"));
|
|
42
51
|
url.searchParams.delete("sslmode");
|
|
43
52
|
this.db = new Kysely({
|
|
@@ -45,10 +54,10 @@ export class SqlConnectionResource implements ResourceInstance {
|
|
|
45
54
|
pool: new Pool({
|
|
46
55
|
connectionString: url.toString(),
|
|
47
56
|
ssl,
|
|
48
|
-
min:
|
|
49
|
-
max:
|
|
50
|
-
idleTimeoutMillis:
|
|
51
|
-
connectionTimeoutMillis:
|
|
57
|
+
min: config.pool?.min ?? 1,
|
|
58
|
+
max: config.pool?.max ?? 10,
|
|
59
|
+
idleTimeoutMillis: config.pool?.idleTimeoutMs,
|
|
60
|
+
connectionTimeoutMillis: config.pool?.connectionTimeoutMs,
|
|
52
61
|
}),
|
|
53
62
|
}),
|
|
54
63
|
});
|
|
@@ -58,7 +67,7 @@ export class SqlConnectionResource implements ResourceInstance {
|
|
|
58
67
|
}
|
|
59
68
|
this.sqlite = sqlite;
|
|
60
69
|
this.db = new Kysely({
|
|
61
|
-
dialect: new
|
|
70
|
+
dialect: new TransactionalSqliteDialect({
|
|
62
71
|
database: this.sqlite,
|
|
63
72
|
}),
|
|
64
73
|
});
|
|
@@ -97,6 +106,29 @@ export class SqlConnectionResource implements ResourceInstance {
|
|
|
97
106
|
return executor.executeQuery<T>(CompiledQuery.raw(sql, params));
|
|
98
107
|
}
|
|
99
108
|
|
|
109
|
+
get placeholderStyle(): PlaceholderStyle {
|
|
110
|
+
return this.driver === "postgres" ? "numbered" : "qmark";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Assemble SQL from literal fragments by interleaving driver-native
|
|
114
|
+
* placeholders, then bind `values` positionally. `fragments.length` must
|
|
115
|
+
* equal `values.length + 1`. */
|
|
116
|
+
async executeTemplate<T>(
|
|
117
|
+
fragments: string[],
|
|
118
|
+
values: unknown[],
|
|
119
|
+
transaction?: SqlTransactionResource,
|
|
120
|
+
): Promise<QueryResult<T>> {
|
|
121
|
+
let sql = fragments[0] ?? "";
|
|
122
|
+
for (let i = 1; i < fragments.length; i++) {
|
|
123
|
+
sql += this.placeholder(i) + fragments[i];
|
|
124
|
+
}
|
|
125
|
+
return this.execute<T>(sql, values, transaction);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private placeholder(index: number): string {
|
|
129
|
+
return this.placeholderStyle === "numbered" ? `$${index}` : "?";
|
|
130
|
+
}
|
|
131
|
+
|
|
100
132
|
async executeScript(sql: string): Promise<void> {
|
|
101
133
|
if (this.driver === "sqlite") {
|
|
102
134
|
this.sqlite?.exec(sql);
|
|
@@ -139,45 +171,25 @@ export class SqlConnectionResource implements ResourceInstance {
|
|
|
139
171
|
}
|
|
140
172
|
}
|
|
141
173
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
):
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
? await openSqliteDatabase(sqliteTargetFromConnectionString(resource.connectionString))
|
|
151
|
-
: undefined;
|
|
152
|
-
return new SqlConnectionResource(resource, sqlite);
|
|
174
|
+
// Kysely's stock SQLite adapter reports `supportsTransactionalDdl = false`, so
|
|
175
|
+
// its Migrator runs migrations without a transaction. SQLite does support
|
|
176
|
+
// transactional DDL, so we flip the flag — letting the Migrator wrap the whole
|
|
177
|
+
// migration batch in a single transaction, matching PostgreSQL.
|
|
178
|
+
class TransactionalSqliteAdapter extends SqliteAdapter {
|
|
179
|
+
override get supportsTransactionalDdl(): boolean {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
153
182
|
}
|
|
154
183
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
function driverFromConnectionString(connectionString: string): SqlDriver {
|
|
160
|
-
const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(connectionString)?.[1]?.toLowerCase();
|
|
161
|
-
switch (scheme) {
|
|
162
|
-
case "postgres":
|
|
163
|
-
case "postgresql":
|
|
164
|
-
return "postgres";
|
|
165
|
-
case "sqlite":
|
|
166
|
-
return "sqlite";
|
|
167
|
-
default:
|
|
168
|
-
throw new Error(
|
|
169
|
-
`Sql.Connection: connectionString must start with a driver scheme — ` +
|
|
170
|
-
`'postgres://' or 'postgresql://' for PostgreSQL, 'sqlite:' for SQLite. ` +
|
|
171
|
-
`Got ${scheme ? `'${scheme}:'` : "a string with no scheme"}: ${JSON.stringify(connectionString)}`,
|
|
172
|
-
);
|
|
184
|
+
class TransactionalSqliteDialect extends SqliteDialect {
|
|
185
|
+
override createAdapter(): SqliteAdapter {
|
|
186
|
+
return new TransactionalSqliteAdapter();
|
|
173
187
|
}
|
|
174
188
|
}
|
|
175
189
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
return path === "" || path === "/" ? ":memory:" : path;
|
|
180
|
-
}
|
|
190
|
+
type SslOption =
|
|
191
|
+
| false
|
|
192
|
+
| { rejectUnauthorized: boolean; checkServerIdentity?: () => undefined };
|
|
181
193
|
|
|
182
194
|
function sslFromSslmode(mode: string | null): SslOption {
|
|
183
195
|
switch (mode) {
|
|
@@ -200,7 +212,7 @@ function sslFromSslmode(mode: string | null): SslOption {
|
|
|
200
212
|
}
|
|
201
213
|
}
|
|
202
214
|
|
|
203
|
-
async function openSqliteDatabase(file = ":memory:"): Promise<SqliteDb> {
|
|
215
|
+
export async function openSqliteDatabase(file = ":memory:"): Promise<SqliteDb> {
|
|
204
216
|
// Auto-create the parent directory for file-backed databases. SQLite
|
|
205
217
|
// drivers fail-fast when the directory doesn't exist; mirroring `mkdir
|
|
206
218
|
// -p` here lets manifests use paths like `./tmp/foo.sqlite` without a
|
|
@@ -2,6 +2,7 @@ import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
|
2
2
|
import type { SqlConnectionResource } from "./sql-connection-controller.js";
|
|
3
3
|
import { resolveSqlConnection } from "./sql-connection-ref.js";
|
|
4
4
|
import type { SqlResult } from "./sql-query-controller.js";
|
|
5
|
+
import { runSql } from "./sql-run.js";
|
|
5
6
|
import type { SqlTransactionResource } from "./sql-transaction-controller.js";
|
|
6
7
|
|
|
7
8
|
interface SqlExecManifest {
|
|
@@ -20,30 +21,20 @@ class SqlExecResource implements ResourceInstance {
|
|
|
20
21
|
private readonly ctx: ResourceContext,
|
|
21
22
|
) {}
|
|
22
23
|
|
|
23
|
-
async invoke(input:
|
|
24
|
+
async invoke(input: unknown): Promise<SqlResult> {
|
|
24
25
|
const m = this.manifest;
|
|
25
26
|
const ctx = this.ctx;
|
|
26
|
-
const expandedInput = ctx.expandValue(input, {});
|
|
27
27
|
|
|
28
28
|
const connection = resolveSqlConnection(m.connection, ctx) ?? m.transaction?.getConnection();
|
|
29
29
|
if (!connection) {
|
|
30
30
|
throw new Error("Sql: either 'connection' or 'transaction' must be set");
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
const result = await runSql(connection, m.transaction, input, ctx);
|
|
34
|
+
return { rows: result.rows, rowCount: connection.toRowCount(result) };
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
async function runExec(
|
|
38
|
-
connection: SqlConnectionResource,
|
|
39
|
-
transaction: SqlTransactionResource | undefined,
|
|
40
|
-
sql: string,
|
|
41
|
-
params: unknown[],
|
|
42
|
-
): Promise<SqlResult> {
|
|
43
|
-
const result = await connection.execute<Record<string, unknown>>(sql, params, transaction);
|
|
44
|
-
return { rows: result.rows, rowCount: connection.toRowCount(result) };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
38
|
export function register(): void {}
|
|
48
39
|
|
|
49
40
|
export async function create(
|
|
@@ -9,26 +9,38 @@ import {
|
|
|
9
9
|
import type { SqlConnectionResource } from "./sql-connection-controller.js";
|
|
10
10
|
import { resolveSqlConnection } from "./sql-connection-ref.js";
|
|
11
11
|
|
|
12
|
+
// A migration entry is one statement or an ordered list of statements; both
|
|
13
|
+
// forms normalize to a non-empty array of single statements.
|
|
14
|
+
interface MigrationEntry {
|
|
15
|
+
statement?: string;
|
|
16
|
+
statements?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
12
19
|
interface SqlMigrationsManifest {
|
|
13
20
|
metadata: { name: string; module: string };
|
|
14
21
|
connection: SqlConnectionResource;
|
|
22
|
+
migrations?: Record<string, MigrationEntry>;
|
|
15
23
|
}
|
|
16
24
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
sql: string;
|
|
25
|
+
function entryStatements(entry: MigrationEntry): string[] {
|
|
26
|
+
return entry.statements ?? (entry.statement != null ? [entry.statement] : []);
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
class TeloMigrationProvider implements MigrationProvider {
|
|
23
|
-
constructor(private readonly migrations:
|
|
30
|
+
constructor(private readonly migrations: Record<string, string[]>) {}
|
|
24
31
|
|
|
25
32
|
async getMigrations(): Promise<Record<string, Migration>> {
|
|
26
33
|
return Object.fromEntries(
|
|
27
|
-
this.migrations.map((
|
|
28
|
-
|
|
34
|
+
Object.entries(this.migrations).map(([name, statements]) => [
|
|
35
|
+
name,
|
|
29
36
|
{
|
|
37
|
+
// Each statement runs as its own prepared statement on the migration
|
|
38
|
+
// transaction's connection, so a migration may hold multiple
|
|
39
|
+
// statements while the whole batch stays a single transaction.
|
|
30
40
|
async up(db: Kysely<any>): Promise<void> {
|
|
31
|
-
|
|
41
|
+
for (const statement of statements) {
|
|
42
|
+
await db.executeQuery(CompiledQuery.raw(statement));
|
|
43
|
+
}
|
|
32
44
|
},
|
|
33
45
|
},
|
|
34
46
|
]),
|
|
@@ -46,17 +58,25 @@ class SqlMigrationsResource implements ResourceInstance {
|
|
|
46
58
|
const conn =
|
|
47
59
|
resolveSqlConnection(this.manifest.connection, this.ctx) ?? failMissingConnection();
|
|
48
60
|
|
|
49
|
-
const migrations:
|
|
61
|
+
const migrations: Record<string, string[]> = {};
|
|
62
|
+
// Legacy: standalone `Sql.Migration` resources in the same module scope.
|
|
50
63
|
for (const [, { resource }] of this.ctx.moduleContext.resourceInstances) {
|
|
51
64
|
if (resource.kind === "Sql.Migration") {
|
|
52
65
|
const version = (resource.version ?? resource.metadata.name) as string;
|
|
53
|
-
migrations.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
66
|
+
migrations[version] = [resource.sql as string];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Preferred: the keyed `migrations` map on this resource.
|
|
70
|
+
for (const [name, entry] of Object.entries(this.manifest.migrations ?? {})) {
|
|
71
|
+
const statements = entryStatements(entry);
|
|
72
|
+
if (statements.length === 0) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Sql.Migrations: migration '${name}' has no statement(s) — ` +
|
|
75
|
+
`set 'statement' or a non-empty 'statements'`,
|
|
76
|
+
);
|
|
57
77
|
}
|
|
78
|
+
migrations[name] = statements;
|
|
58
79
|
}
|
|
59
|
-
migrations.sort((a, b) => a.name.localeCompare(b.name));
|
|
60
80
|
|
|
61
81
|
const migrator = new Migrator({
|
|
62
82
|
db: conn.kysely,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ResourceContext } from "@telorun/sdk";
|
|
2
|
+
import { SqlConnectionResource } from "./sql-connection-controller.js";
|
|
3
|
+
|
|
4
|
+
interface PoolConfig {
|
|
5
|
+
min?: number;
|
|
6
|
+
max?: number;
|
|
7
|
+
idleTimeoutMs?: number;
|
|
8
|
+
connectionTimeoutMs?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface PostgresConnectionManifest {
|
|
12
|
+
metadata: { name: string; module: string };
|
|
13
|
+
connectionString: string;
|
|
14
|
+
pool?: PoolConfig;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function register(): void {}
|
|
18
|
+
|
|
19
|
+
export async function create(
|
|
20
|
+
resource: PostgresConnectionManifest,
|
|
21
|
+
ctx: ResourceContext,
|
|
22
|
+
): Promise<SqlConnectionResource> {
|
|
23
|
+
return new SqlConnectionResource({
|
|
24
|
+
driver: "postgres",
|
|
25
|
+
connectionString: resource.connectionString,
|
|
26
|
+
pool: resource.pool,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
2
|
import type { SqlConnectionResource } from "./sql-connection-controller.js";
|
|
3
3
|
import { resolveSqlConnection } from "./sql-connection-ref.js";
|
|
4
|
+
import { runSql } from "./sql-run.js";
|
|
4
5
|
import type { SqlTransactionResource } from "./sql-transaction-controller.js";
|
|
5
6
|
|
|
6
7
|
interface SqlQueryManifest {
|
|
@@ -27,10 +28,9 @@ class SqlQueryResource implements ResourceInstance {
|
|
|
27
28
|
async invoke(input: unknown): Promise<SqlResult> {
|
|
28
29
|
const m = this.manifest;
|
|
29
30
|
const ctx = this.ctx;
|
|
30
|
-
const expandedInput = ctx.expandValue(input, {});
|
|
31
|
-
|
|
32
31
|
const connection = resolveConnection(m.connection, m.transaction, ctx);
|
|
33
|
-
|
|
32
|
+
const result = await runSql(connection, m.transaction, input, ctx);
|
|
33
|
+
return { rows: result.rows, rowCount: result.rows.length };
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -44,16 +44,6 @@ function resolveConnection(
|
|
|
44
44
|
);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
async function runQuery(
|
|
48
|
-
connection: SqlConnectionResource,
|
|
49
|
-
transaction: SqlTransactionResource | undefined,
|
|
50
|
-
sql: string,
|
|
51
|
-
params: unknown[],
|
|
52
|
-
): Promise<SqlResult> {
|
|
53
|
-
const result = await connection.execute<Record<string, unknown>>(sql, params, transaction);
|
|
54
|
-
return { rows: result.rows, rowCount: result.rows.length };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
47
|
function failMissingConnection(): never {
|
|
58
48
|
throw new Error("Sql: either 'connection' or 'transaction' must be set");
|
|
59
49
|
}
|
package/src/sql-run.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { InvokeError, isParameterizedSql, type ResourceContext } from "@telorun/sdk";
|
|
2
|
+
import type { QueryResult } from "kysely";
|
|
3
|
+
import type { SqlConnectionResource } from "./sql-connection-controller.js";
|
|
4
|
+
import type { SqlTransactionResource } from "./sql-transaction-controller.js";
|
|
5
|
+
|
|
6
|
+
/** Execute the `sql` input of a Query/Exec resource against `connection`.
|
|
7
|
+
*
|
|
8
|
+
* Two modes:
|
|
9
|
+
* - **inline (`!sql` tag):** the expanded `sql` is a parameterized template —
|
|
10
|
+
* each `${{ }}` is bound via the connection's native placeholder, never
|
|
11
|
+
* spliced into the text.
|
|
12
|
+
* - **escape hatch (`bindings` given):** `sql` is a literal string with
|
|
13
|
+
* author-written `?` / `$n` placeholders and `bindings` bound positionally.
|
|
14
|
+
*
|
|
15
|
+
* Mixing a `!sql` template with `bindings` is rejected. A plain string `sql`
|
|
16
|
+
* with neither is executed verbatim. */
|
|
17
|
+
export async function runSql(
|
|
18
|
+
connection: SqlConnectionResource,
|
|
19
|
+
transaction: SqlTransactionResource | undefined,
|
|
20
|
+
input: unknown,
|
|
21
|
+
ctx: ResourceContext,
|
|
22
|
+
): Promise<QueryResult<Record<string, unknown>>> {
|
|
23
|
+
const expanded = ctx.expandValue(input, {}) as { sql: unknown; bindings?: unknown[] };
|
|
24
|
+
const sql = expanded.sql;
|
|
25
|
+
const bindings = expanded.bindings;
|
|
26
|
+
const hasBindings = bindings !== undefined && bindings !== null;
|
|
27
|
+
|
|
28
|
+
if (isParameterizedSql(sql)) {
|
|
29
|
+
if (hasBindings) {
|
|
30
|
+
throw new InvokeError(
|
|
31
|
+
"ERR_INVALID_INPUT",
|
|
32
|
+
"Sql: a `!sql` template cannot be combined with an explicit `bindings` array. " +
|
|
33
|
+
"Use one or the other — `!sql` binds each inline value automatically; " +
|
|
34
|
+
"`bindings` is for hand-written ? / $n placeholders.",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return connection.executeTemplate<Record<string, unknown>>(
|
|
38
|
+
sql.fragments,
|
|
39
|
+
sql.values,
|
|
40
|
+
transaction,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return connection.execute<Record<string, unknown>>(
|
|
45
|
+
sql as string,
|
|
46
|
+
hasBindings ? (bindings as unknown[]) : [],
|
|
47
|
+
transaction,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ResourceContext } from "@telorun/sdk";
|
|
2
|
+
import { openSqliteDatabase, SqlConnectionResource } from "./sql-connection-controller.js";
|
|
3
|
+
|
|
4
|
+
interface SqliteConnectionManifest {
|
|
5
|
+
metadata: { name: string; module: string };
|
|
6
|
+
/** File path, or omitted / `:memory:` for an in-memory database. */
|
|
7
|
+
file?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function register(): void {}
|
|
11
|
+
|
|
12
|
+
export async function create(
|
|
13
|
+
resource: SqliteConnectionManifest,
|
|
14
|
+
ctx: ResourceContext,
|
|
15
|
+
): Promise<SqlConnectionResource> {
|
|
16
|
+
const sqlite = await openSqliteDatabase(resource.file ?? ":memory:");
|
|
17
|
+
return new SqlConnectionResource({ driver: "sqlite" }, sqlite);
|
|
18
|
+
}
|