@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 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 { ResourceContext, ResourceInstance } from "@telorun/sdk";
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
- interface SqlConnectionManifest {
12
- metadata: {
13
- name: string;
14
- module: string;
15
- };
16
- connectionString: string;
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(m: SqlConnectionManifest, sqlite?: SqliteDb);
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 register(): void;
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(m, sqlite) {
10
- this.driver = driverFromConnectionString(m.connectionString);
9
+ constructor(config, sqlite) {
10
+ this.driver = config.driver;
11
11
  if (this.driver === "postgres") {
12
- const url = new URL(m.connectionString);
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: m.pool?.min ?? 1,
21
- max: m.pool?.max ?? 10,
22
- idleTimeoutMillis: m.pool?.idleTimeoutMs,
23
- connectionTimeoutMillis: m.pool?.connectionTimeoutMs,
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 SqliteDialect({
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
- export function register() { }
98
- export async function create(resource, ctx) {
99
- const sqlite = driverFromConnectionString(resource.connectionString) === "sqlite"
100
- ? await openSqliteDatabase(sqliteTargetFromConnectionString(resource.connectionString))
101
- : undefined;
102
- return new SqlConnectionResource(resource, sqlite);
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
- function sqliteTargetFromConnectionString(connectionString) {
119
- const path = decodeURIComponent(new URL(connectionString).pathname);
120
- // `sqlite:` / `sqlite://` with no path resolves to an in-memory database.
121
- return path === "" || path === "/" ? ":memory:" : path;
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: any): Promise<SqlResult>;
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
- return runExec(connection, m.transaction, expandedInput.sql, expandedInput.bindings ?? []);
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((m) => [
10
- m.name,
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
- await db.executeQuery(CompiledQuery.raw(m.sql));
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.push({
33
- name: version,
34
- sql: resource.sql,
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
- return runQuery(connection, m.transaction, expandedInput.sql, expandedInput.bindings);
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>>>;
@@ -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.5.1",
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.16.0"
77
+ "@telorun/sdk": "0.21.0"
74
78
  },
75
79
  "peerDependencies": {
76
80
  "@telorun/sdk": "*"
@@ -1,9 +1,10 @@
1
- import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
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
- interface SqlConnectionManifest {
24
- metadata: { name: string; module: string };
25
- connectionString: string;
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(m: SqlConnectionManifest, sqlite?: SqliteDb) {
37
- this.driver = driverFromConnectionString(m.connectionString);
42
+ constructor(config: SqlConnectionConfig, sqlite?: SqliteDb) {
43
+ this.driver = config.driver;
38
44
 
39
45
  if (this.driver === "postgres") {
40
- const url = new URL(m.connectionString);
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: m.pool?.min ?? 1,
49
- max: m.pool?.max ?? 10,
50
- idleTimeoutMillis: m.pool?.idleTimeoutMs,
51
- connectionTimeoutMillis: m.pool?.connectionTimeoutMs,
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 SqliteDialect({
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
- export function register(): void {}
143
-
144
- export async function create(
145
- resource: SqlConnectionManifest,
146
- ctx: ResourceContext,
147
- ): Promise<SqlConnectionResource> {
148
- const sqlite =
149
- driverFromConnectionString(resource.connectionString) === "sqlite"
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
- type SslOption =
156
- | false
157
- | { rejectUnauthorized: boolean; checkServerIdentity?: () => undefined };
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
- function sqliteTargetFromConnectionString(connectionString: string): string {
177
- const path = decodeURIComponent(new URL(connectionString).pathname);
178
- // `sqlite:` / `sqlite://` with no path resolves to an in-memory database.
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: any): Promise<SqlResult> {
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
- return runExec(connection, m.transaction, expandedInput.sql, expandedInput.bindings ?? []);
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
- interface MigrationEntry {
18
- name: string;
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: MigrationEntry[]) {}
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((m) => [
28
- m.name,
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
- await db.executeQuery(CompiledQuery.raw(m.sql));
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: MigrationEntry[] = [];
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.push({
54
- name: version,
55
- sql: resource.sql as string,
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
- return runQuery(connection, m.transaction, expandedInput.sql, expandedInput.bindings);
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
+ }