@telorun/sql 0.1.1 → 0.1.2

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.
@@ -0,0 +1,69 @@
1
+ import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
2
+ import type { SqlConnectionResource } from "./sql-connection-controller.js";
3
+ import type { SqlResult } from "./sql-query-controller.js";
4
+ import type { SqlTransactionResource } from "./sql-transaction-controller.js";
5
+ type ColumnDef = string | {
6
+ column: string;
7
+ as?: string;
8
+ } | {
9
+ expr: string;
10
+ as?: string;
11
+ };
12
+ type Op = "eq" | "ne" | "lt" | "lte" | "gt" | "gte" | "like" | "ilike" | "in" | "is_null" | "is_not_null";
13
+ interface Condition {
14
+ when?: boolean;
15
+ column: string;
16
+ op: Op;
17
+ value?: unknown;
18
+ ref?: string;
19
+ }
20
+ interface RawClause {
21
+ when?: boolean;
22
+ sql: string;
23
+ bindings?: unknown[];
24
+ }
25
+ interface OrGroup {
26
+ when?: boolean;
27
+ or: WhereNode[];
28
+ }
29
+ interface AndGroup {
30
+ when?: boolean;
31
+ and: WhereNode[];
32
+ }
33
+ interface NotGroup {
34
+ when?: boolean;
35
+ not: WhereNode;
36
+ }
37
+ type WhereNode = Condition | RawClause | OrGroup | AndGroup | NotGroup;
38
+ interface OrderByItem {
39
+ column: string;
40
+ direction?: "asc" | "desc";
41
+ }
42
+ interface SelectManifest {
43
+ metadata: {
44
+ name: string;
45
+ module: string;
46
+ };
47
+ connection?: SqlConnectionResource;
48
+ transaction?: SqlTransactionResource;
49
+ from: string;
50
+ columns?: ColumnDef[];
51
+ distinct?: boolean;
52
+ distinctOn?: string[];
53
+ where?: WhereNode[];
54
+ groupBy?: string[];
55
+ having?: WhereNode[];
56
+ orderBy?: OrderByItem[];
57
+ limit?: unknown;
58
+ offset?: unknown;
59
+ inputType?: string | Record<string, any>;
60
+ }
61
+ declare class SqlSelectResource implements ResourceInstance {
62
+ private readonly manifest;
63
+ private readonly ctx;
64
+ constructor(manifest: SelectManifest, ctx: ResourceContext);
65
+ invoke(input: unknown): Promise<SqlResult>;
66
+ }
67
+ export declare function register(): void;
68
+ export declare function create(resource: SelectManifest, ctx: ResourceContext): Promise<SqlSelectResource>;
69
+ export {};
@@ -0,0 +1,194 @@
1
+ import { resolveSqlConnection } from "./sql-connection-ref.js";
2
+ // ── Controller ────────────────────────────────────────────────────────────────
3
+ class SqlSelectResource {
4
+ manifest;
5
+ ctx;
6
+ constructor(manifest, ctx) {
7
+ this.manifest = manifest;
8
+ this.ctx = ctx;
9
+ }
10
+ async invoke(input) {
11
+ const m = this.manifest;
12
+ const ctx = this.ctx;
13
+ const inputs = {
14
+ ...extractDefaults(m.inputType, ctx),
15
+ ...(input ?? {}),
16
+ };
17
+ const expandCtx = { inputs };
18
+ const where = ctx.expandValue(m.where ?? [], expandCtx);
19
+ const having = ctx.expandValue(m.having ?? [], expandCtx);
20
+ const limit = m.limit != null ? ctx.expandValue(m.limit, expandCtx) : undefined;
21
+ const offset = m.offset != null ? ctx.expandValue(m.offset, expandCtx) : undefined;
22
+ const connection = resolveSqlConnection(m.connection, ctx) ?? m.transaction?.getConnection();
23
+ if (!connection) {
24
+ throw new Error("Sql.Select: either 'connection' or 'transaction' must be set");
25
+ }
26
+ const { sql, params } = buildSelect(m, where, having, limit, offset, connection.driver);
27
+ const result = await connection.execute(sql, params, m.transaction);
28
+ return { rows: result.rows, rowCount: result.rows.length };
29
+ }
30
+ }
31
+ function buildSelect(m, where, having, limit, offset, driver) {
32
+ const params = [];
33
+ const addParam = (value) => {
34
+ params.push(value);
35
+ return `$${params.length}`;
36
+ };
37
+ const parts = [];
38
+ // SELECT [DISTINCT [ON (...)]]
39
+ let selectClause = "SELECT";
40
+ if (m.distinct) {
41
+ selectClause += " DISTINCT";
42
+ }
43
+ else if (m.distinctOn && m.distinctOn.length > 0) {
44
+ selectClause += ` DISTINCT ON (${m.distinctOn.map(quoteIdent).join(", ")})`;
45
+ }
46
+ const colList = m.columns && m.columns.length > 0 ? buildColumns(m.columns) : "*";
47
+ parts.push(`${selectClause} ${colList}`);
48
+ // FROM
49
+ parts.push(`FROM ${quoteIdent(m.from)}`);
50
+ // WHERE
51
+ const whereStr = buildClauses(where, "AND", driver, addParam);
52
+ if (whereStr)
53
+ parts.push(`WHERE ${whereStr}`);
54
+ // GROUP BY
55
+ if (m.groupBy && m.groupBy.length > 0) {
56
+ parts.push(`GROUP BY ${m.groupBy.map(quoteIdent).join(", ")}`);
57
+ }
58
+ // HAVING
59
+ const havingStr = buildClauses(having, "AND", driver, addParam);
60
+ if (havingStr)
61
+ parts.push(`HAVING ${havingStr}`);
62
+ // ORDER BY
63
+ if (m.orderBy && m.orderBy.length > 0) {
64
+ const orderParts = m.orderBy.map((o) => `${quoteIdent(o.column)} ${(o.direction ?? "asc").toUpperCase()}`);
65
+ parts.push(`ORDER BY ${orderParts.join(", ")}`);
66
+ }
67
+ // LIMIT / OFFSET
68
+ if (limit != null)
69
+ parts.push(`LIMIT ${addParam(limit)}`);
70
+ if (offset != null)
71
+ parts.push(`OFFSET ${addParam(offset)}`);
72
+ return { sql: parts.join("\n"), params };
73
+ }
74
+ function buildColumns(columns) {
75
+ return columns
76
+ .map((c) => {
77
+ if (typeof c === "string")
78
+ return quoteIdent(c);
79
+ if ("expr" in c)
80
+ return c.as ? `${c.expr} AS ${quoteIdent(c.as)}` : c.expr;
81
+ return c.as ? `${quoteIdent(c.column)} AS ${quoteIdent(c.as)}` : quoteIdent(c.column);
82
+ })
83
+ .join(", ");
84
+ }
85
+ function buildClauses(clauses, join, driver, addParam) {
86
+ const parts = [];
87
+ for (const clause of clauses) {
88
+ if (clause.when === false)
89
+ continue;
90
+ const built = buildClause(clause, driver, addParam);
91
+ if (built !== null)
92
+ parts.push(built);
93
+ }
94
+ if (parts.length === 0)
95
+ return null;
96
+ if (parts.length === 1)
97
+ return parts[0];
98
+ return parts.join(` ${join} `);
99
+ }
100
+ function buildClause(node, driver, addParam) {
101
+ if ("not" in node) {
102
+ const inner = buildClause(node.not, driver, addParam);
103
+ return inner ? `NOT (${inner})` : null;
104
+ }
105
+ if ("or" in node) {
106
+ const inner = buildClauses(node.or, "OR", driver, addParam);
107
+ return inner ? `(${inner})` : null;
108
+ }
109
+ if ("and" in node) {
110
+ const inner = buildClauses(node.and, "AND", driver, addParam);
111
+ return inner ? `(${inner})` : null;
112
+ }
113
+ if ("sql" in node) {
114
+ return renumberFragment(node.sql, node.bindings ?? [], addParam);
115
+ }
116
+ if ("column" in node) {
117
+ return buildCondition(node, driver, addParam);
118
+ }
119
+ return null;
120
+ }
121
+ function buildCondition(c, driver, addParam) {
122
+ const col = quoteIdent(c.column);
123
+ switch (c.op) {
124
+ case "is_null":
125
+ return `${col} IS NULL`;
126
+ case "is_not_null":
127
+ return `${col} IS NOT NULL`;
128
+ case "in": {
129
+ if (driver === "postgres") {
130
+ return `${col} = ANY(${addParam(c.value)})`;
131
+ }
132
+ const placeholders = c.value.map((v) => addParam(v)).join(", ");
133
+ return `${col} IN (${placeholders})`;
134
+ }
135
+ default: {
136
+ const rhs = c.ref !== undefined ? quoteIdent(c.ref) : addParam(c.value);
137
+ return `${col} ${opToSql(c.op)} ${rhs}`;
138
+ }
139
+ }
140
+ }
141
+ function renumberFragment(sql, bindings, addParam) {
142
+ return sql.replace(/\$(\d+)/g, (_, idx) => addParam(bindings[Number(idx) - 1]));
143
+ }
144
+ function quoteIdent(name) {
145
+ return `"${name.replace(/"/g, '""')}"`;
146
+ }
147
+ function opToSql(op) {
148
+ const map = {
149
+ eq: "=",
150
+ ne: "<>",
151
+ lt: "<",
152
+ lte: "<=",
153
+ gt: ">",
154
+ gte: ">=",
155
+ like: "LIKE",
156
+ ilike: "ILIKE",
157
+ };
158
+ const sql = map[op];
159
+ if (!sql)
160
+ throw new Error(`Sql.Select: unknown operator '${op}'`);
161
+ return sql;
162
+ }
163
+ // ── Exports ───────────────────────────────────────────────────────────────────
164
+ function extractDefaults(inputType, ctx) {
165
+ if (!inputType)
166
+ return {};
167
+ // Resolve schema: string ref → look up, inline object → use directly
168
+ let schema;
169
+ if (typeof inputType === "string") {
170
+ schema = ctx.lookupSchema(inputType);
171
+ }
172
+ else if (inputType.schema && typeof inputType.schema === "object") {
173
+ schema = inputType.schema;
174
+ }
175
+ else {
176
+ schema = inputType;
177
+ }
178
+ if (!schema || typeof schema !== "object")
179
+ return {};
180
+ const props = schema.properties;
181
+ if (!props)
182
+ return {};
183
+ const defaults = {};
184
+ for (const [key, def] of Object.entries(props)) {
185
+ if (def && typeof def === "object" && "default" in def) {
186
+ defaults[key] = def.default;
187
+ }
188
+ }
189
+ return defaults;
190
+ }
191
+ export function register() { }
192
+ export async function create(resource, ctx) {
193
+ return new SqlSelectResource(resource, ctx);
194
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/sql",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./sql-connection": {
@@ -11,6 +11,10 @@
11
11
  "bun": "./src/sql-query-controller.ts",
12
12
  "import": "./dist/sql-query-controller.js"
13
13
  },
14
+ "./sql-select": {
15
+ "bun": "./src/sql-select-controller.ts",
16
+ "import": "./dist/sql-select-controller.js"
17
+ },
14
18
  "./sql-exec": {
15
19
  "bun": "./src/sql-exec-controller.ts",
16
20
  "import": "./dist/sql-exec-controller.js"
@@ -33,13 +37,14 @@
33
37
  }
34
38
  },
35
39
  "files": [
36
- "dist/**"
40
+ "dist",
41
+ "src/**"
37
42
  ],
38
43
  "dependencies": {
39
44
  "better-sqlite3": "^12.8.0",
40
45
  "pg": "^8.20.0",
41
46
  "kysely": "^0.28.15",
42
- "@telorun/sdk": "0.2.6"
47
+ "@telorun/sdk": "0.2.7"
43
48
  },
44
49
  "devDependencies": {
45
50
  "@types/better-sqlite3": "^7.0.0",
@@ -0,0 +1,169 @@
1
+ import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
2
+ import { randomUUID } from "crypto";
3
+ import {
4
+ CompiledQuery,
5
+ Kysely,
6
+ PostgresDialect,
7
+ SqliteDialect,
8
+ type QueryResult,
9
+ type Transaction,
10
+ } from "kysely";
11
+ import { Pool } from "pg";
12
+ import type { SqlTransactionResource } from "./sql-transaction-controller.js";
13
+ import type { SqliteDb } from "./sqlite-driver-interface.js";
14
+ import { currentTxId, deleteTx, getTx, setTx, txStorage } from "./transaction-store.js";
15
+
16
+ interface PoolConfig {
17
+ min?: number;
18
+ max?: number;
19
+ idleTimeoutMs?: number;
20
+ connectionTimeoutMs?: number;
21
+ }
22
+
23
+ interface SqlConnectionManifest {
24
+ metadata: { name: string; module: string };
25
+ driver: "postgres" | "sqlite";
26
+ connectionString?: string;
27
+ host?: string;
28
+ port?: number;
29
+ database?: string;
30
+ user?: string;
31
+ password?: string;
32
+ ssl?: boolean;
33
+ file?: string;
34
+ pool?: PoolConfig;
35
+ }
36
+
37
+ export type SqlDriver = SqlConnectionManifest["driver"];
38
+
39
+ export class SqlConnectionResource implements ResourceInstance {
40
+ readonly driver: SqlDriver;
41
+ private readonly db: Kysely<any>;
42
+ private readonly sqlite?: SqliteDb;
43
+
44
+ constructor(m: SqlConnectionManifest, sqlite?: SqliteDb) {
45
+ this.driver = m.driver;
46
+
47
+ if (m.driver === "postgres") {
48
+ this.db = new Kysely({
49
+ dialect: new PostgresDialect({
50
+ pool: new Pool({
51
+ ...(m.connectionString
52
+ ? { connectionString: m.connectionString }
53
+ : { host: m.host, port: m.port ?? 5432, database: m.database, user: m.user, password: m.password }),
54
+ ssl: m.ssl ? { rejectUnauthorized: false } : false,
55
+ min: m.pool?.min ?? 1,
56
+ max: m.pool?.max ?? 10,
57
+ idleTimeoutMillis: m.pool?.idleTimeoutMs,
58
+ connectionTimeoutMillis: m.pool?.connectionTimeoutMs,
59
+ }),
60
+ }),
61
+ });
62
+ } else if (m.driver === "sqlite") {
63
+ if (!sqlite) {
64
+ throw new Error("Sql: sqlite database was not initialized");
65
+ }
66
+ this.sqlite = sqlite;
67
+ this.db = new Kysely({
68
+ dialect: new SqliteDialect({
69
+ database: this.sqlite,
70
+ }),
71
+ });
72
+ } else {
73
+ throw new Error("Invalid SQL Connection driver");
74
+ }
75
+ }
76
+
77
+ async init() {
78
+ await this.db.connection().execute(async () => {
79
+ // just checking
80
+ });
81
+ }
82
+
83
+ async teardown(): Promise<void> {
84
+ await this.db.destroy();
85
+ }
86
+
87
+ async transaction<T>(cb: () => Promise<T>): Promise<T> {
88
+ const txId = randomUUID();
89
+
90
+ return this.db.transaction().execute(async (trx: Transaction<any>) => {
91
+ setTx(txId, { executor: trx });
92
+ try {
93
+ return await txStorage.run(txId, cb);
94
+ } finally {
95
+ deleteTx(txId);
96
+ }
97
+ });
98
+ }
99
+
100
+ async execute<T>(
101
+ sql: string,
102
+ params: unknown[] = [],
103
+ transaction?: SqlTransactionResource,
104
+ ): Promise<QueryResult<T>> {
105
+ const executor = this.resolveExecutor(transaction);
106
+ return executor.executeQuery<T>(CompiledQuery.raw(sql, params));
107
+ }
108
+
109
+ async executeScript(sql: string): Promise<void> {
110
+ if (this.driver === "sqlite") {
111
+ this.sqlite?.exec(sql);
112
+ return;
113
+ }
114
+
115
+ await this.execute(sql);
116
+ }
117
+
118
+ toRowCount(result: QueryResult<unknown>): number {
119
+ if (result.numAffectedRows !== undefined) {
120
+ return Number(result.numAffectedRows);
121
+ }
122
+
123
+ return result.rows.length;
124
+ }
125
+
126
+ get kysely(): Kysely<any> {
127
+ return this.db;
128
+ }
129
+
130
+ snapshot(): Record<string, unknown> {
131
+ return {};
132
+ }
133
+
134
+ private resolveExecutor(transaction?: SqlTransactionResource): Kysely<any> {
135
+ if (transaction) {
136
+ transaction.assertActive();
137
+ }
138
+
139
+ const txId = currentTxId();
140
+ if (txId) {
141
+ const entry = getTx(txId);
142
+ if (entry) {
143
+ return entry.executor as Kysely<any>;
144
+ }
145
+ }
146
+
147
+ return this.db;
148
+ }
149
+ }
150
+
151
+ export function register(): void {}
152
+
153
+ export async function create(
154
+ resource: SqlConnectionManifest,
155
+ ctx: ResourceContext,
156
+ ): Promise<SqlConnectionResource> {
157
+ const sqlite = resource.driver === "sqlite" ? await openSqliteDatabase(resource.file) : undefined;
158
+ return new SqlConnectionResource(resource, sqlite);
159
+ }
160
+
161
+ async function openSqliteDatabase(file = ":memory:"): Promise<SqliteDb> {
162
+ if (typeof Bun !== "undefined") {
163
+ const { openDatabase } = await import("./sqlite-driver-bun.js");
164
+ return openDatabase(file);
165
+ }
166
+
167
+ const { openDatabase } = await import("./sqlite-driver-node.js");
168
+ return openDatabase(file);
169
+ }
@@ -0,0 +1,25 @@
1
+ import type { ResourceContext } from "@telorun/sdk";
2
+ import type { SqlConnectionResource } from "./sql-connection-controller.js";
3
+
4
+ interface ConnectionRef {
5
+ name: string;
6
+ }
7
+
8
+ export function resolveSqlConnection(
9
+ value: SqlConnectionResource | ConnectionRef | undefined,
10
+ ctx: ResourceContext,
11
+ ): SqlConnectionResource | undefined {
12
+ if (!value) {
13
+ return undefined;
14
+ }
15
+
16
+ if (typeof (value as SqlConnectionResource).execute === "function") {
17
+ return value as SqlConnectionResource;
18
+ }
19
+
20
+ if (typeof (value as ConnectionRef).name !== "string") {
21
+ throw new Error("Sql: invalid connection reference");
22
+ }
23
+
24
+ return ctx.moduleContext.getInstance((value as ConnectionRef).name) as SqlConnectionResource;
25
+ }
@@ -0,0 +1,54 @@
1
+ import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
2
+ import type { SqlConnectionResource } from "./sql-connection-controller.js";
3
+ import { resolveSqlConnection } from "./sql-connection-ref.js";
4
+ import type { SqlResult } from "./sql-query-controller.js";
5
+ import type { SqlTransactionResource } from "./sql-transaction-controller.js";
6
+
7
+ interface SqlExecManifest {
8
+ metadata: { name: string; module: string };
9
+ connection?: SqlConnectionResource;
10
+ transaction?: SqlTransactionResource;
11
+ inputs: {
12
+ sql: string;
13
+ bindings?: unknown[];
14
+ };
15
+ }
16
+
17
+ class SqlExecResource implements ResourceInstance {
18
+ constructor(
19
+ private readonly manifest: SqlExecManifest,
20
+ private readonly ctx: ResourceContext,
21
+ ) {}
22
+
23
+ async invoke(input: any): Promise<SqlResult> {
24
+ const m = this.manifest;
25
+ const ctx = this.ctx;
26
+ const expandedInput = ctx.expandValue(input, {});
27
+
28
+ const connection = resolveSqlConnection(m.connection, ctx) ?? m.transaction?.getConnection();
29
+ if (!connection) {
30
+ throw new Error("Sql: either 'connection' or 'transaction' must be set");
31
+ }
32
+
33
+ return runExec(connection, m.transaction, expandedInput.sql, expandedInput.bindings ?? []);
34
+ }
35
+ }
36
+
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
+ export function register(): void {}
48
+
49
+ export async function create(
50
+ resource: SqlExecManifest,
51
+ ctx: ResourceContext,
52
+ ): Promise<SqlExecResource> {
53
+ return new SqlExecResource(resource, ctx);
54
+ }
@@ -0,0 +1,20 @@
1
+ import type { ResourceInstance } from "@telorun/sdk";
2
+
3
+ interface SqlMigrationManifest {
4
+ metadata: { name: string; module: string };
5
+ sql: string;
6
+ }
7
+
8
+ class SqlMigrationResource implements ResourceInstance {
9
+ constructor(readonly manifest: SqlMigrationManifest) {}
10
+
11
+ snapshot(): Record<string, unknown> {
12
+ return {};
13
+ }
14
+ }
15
+
16
+ export function register(): void {}
17
+
18
+ export async function create(resource: SqlMigrationManifest): Promise<SqlMigrationResource> {
19
+ return new SqlMigrationResource(resource);
20
+ }
@@ -0,0 +1,85 @@
1
+ import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
2
+ import {
3
+ CompiledQuery,
4
+ Migrator,
5
+ type Kysely,
6
+ type Migration,
7
+ type MigrationProvider,
8
+ } from "kysely";
9
+ import type { SqlConnectionResource } from "./sql-connection-controller.js";
10
+ import { resolveSqlConnection } from "./sql-connection-ref.js";
11
+
12
+ interface SqlMigrationsManifest {
13
+ metadata: { name: string; module: string };
14
+ connection: SqlConnectionResource;
15
+ }
16
+
17
+ interface MigrationEntry {
18
+ name: string;
19
+ sql: string;
20
+ }
21
+
22
+ class TeloMigrationProvider implements MigrationProvider {
23
+ constructor(private readonly migrations: MigrationEntry[]) {}
24
+
25
+ async getMigrations(): Promise<Record<string, Migration>> {
26
+ return Object.fromEntries(
27
+ this.migrations.map((m) => [
28
+ m.name,
29
+ {
30
+ async up(db: Kysely<any>): Promise<void> {
31
+ await db.executeQuery(CompiledQuery.raw(m.sql));
32
+ },
33
+ },
34
+ ]),
35
+ );
36
+ }
37
+ }
38
+
39
+ class SqlMigrationsResource implements ResourceInstance {
40
+ constructor(
41
+ private readonly manifest: SqlMigrationsManifest,
42
+ private readonly ctx: ResourceContext,
43
+ ) {}
44
+
45
+ async run(): Promise<void> {
46
+ const conn =
47
+ resolveSqlConnection(this.manifest.connection, this.ctx) ?? failMissingConnection();
48
+
49
+ const migrations: MigrationEntry[] = [];
50
+ for (const [, { resource }] of this.ctx.moduleContext.resourceInstances) {
51
+ if (resource.kind === "Sql.Migration") {
52
+ migrations.push({
53
+ name: resource.metadata.name as string,
54
+ sql: resource.sql as string,
55
+ });
56
+ }
57
+ }
58
+ migrations.sort((a, b) => a.name.localeCompare(b.name));
59
+
60
+ const migrator = new Migrator({
61
+ db: conn.kysely,
62
+ provider: new TeloMigrationProvider(migrations),
63
+ migrationTableName: "migrations",
64
+ migrationLockTableName: "migration_locks",
65
+ });
66
+
67
+ const { error } = await migrator.migrateToLatest();
68
+ if (error) {
69
+ throw error;
70
+ }
71
+ }
72
+ }
73
+
74
+ function failMissingConnection(): never {
75
+ throw new Error("Sql.Migrations: missing connection");
76
+ }
77
+
78
+ export function register(): void {}
79
+
80
+ export async function create(
81
+ resource: SqlMigrationsManifest,
82
+ ctx: ResourceContext,
83
+ ): Promise<SqlMigrationsResource> {
84
+ return new SqlMigrationsResource(resource, ctx);
85
+ }
@@ -0,0 +1,68 @@
1
+ import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
2
+ import type { SqlConnectionResource } from "./sql-connection-controller.js";
3
+ import { resolveSqlConnection } from "./sql-connection-ref.js";
4
+ import type { SqlTransactionResource } from "./sql-transaction-controller.js";
5
+
6
+ interface SqlQueryManifest {
7
+ metadata: { name: string; module: string };
8
+ connection?: SqlConnectionResource;
9
+ transaction?: SqlTransactionResource;
10
+ inputs: {
11
+ sql: string;
12
+ bindings?: unknown[];
13
+ };
14
+ }
15
+
16
+ export interface SqlResult {
17
+ rows: Record<string, unknown>[];
18
+ rowCount: number;
19
+ }
20
+
21
+ class SqlQueryResource implements ResourceInstance {
22
+ constructor(
23
+ private readonly manifest: SqlQueryManifest,
24
+ private readonly ctx: ResourceContext,
25
+ ) {}
26
+
27
+ async invoke(input: unknown): Promise<SqlResult> {
28
+ const m = this.manifest;
29
+ const ctx = this.ctx;
30
+ const expandedInput = ctx.expandValue(input, {});
31
+
32
+ const connection = resolveConnection(m.connection, m.transaction, ctx);
33
+ return runQuery(connection, m.transaction, expandedInput.sql, expandedInput.bindings);
34
+ }
35
+ }
36
+
37
+ function resolveConnection(
38
+ connection: SqlConnectionResource | undefined,
39
+ transaction: SqlTransactionResource | undefined,
40
+ ctx: ResourceContext,
41
+ ): SqlConnectionResource {
42
+ return (
43
+ resolveSqlConnection(connection, ctx) ?? transaction?.getConnection() ?? failMissingConnection()
44
+ );
45
+ }
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
+ function failMissingConnection(): never {
58
+ throw new Error("Sql: either 'connection' or 'transaction' must be set");
59
+ }
60
+
61
+ export function register(): void {}
62
+
63
+ export async function create(
64
+ resource: SqlQueryManifest,
65
+ ctx: ResourceContext,
66
+ ): Promise<SqlQueryResource> {
67
+ return new SqlQueryResource(resource, ctx);
68
+ }
@@ -0,0 +1,311 @@
1
+ import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
2
+ import type { SqlConnectionResource } from "./sql-connection-controller.js";
3
+ import { resolveSqlConnection } from "./sql-connection-ref.js";
4
+ import type { SqlResult } from "./sql-query-controller.js";
5
+ import type { SqlTransactionResource } from "./sql-transaction-controller.js";
6
+
7
+ // ── Types ─────────────────────────────────────────────────────────────────────
8
+
9
+ type ColumnDef = string | { column: string; as?: string } | { expr: string; as?: string };
10
+
11
+ type Op =
12
+ | "eq"
13
+ | "ne"
14
+ | "lt"
15
+ | "lte"
16
+ | "gt"
17
+ | "gte"
18
+ | "like"
19
+ | "ilike"
20
+ | "in"
21
+ | "is_null"
22
+ | "is_not_null";
23
+
24
+ interface Condition {
25
+ when?: boolean;
26
+ column: string;
27
+ op: Op;
28
+ value?: unknown;
29
+ ref?: string;
30
+ }
31
+
32
+ interface RawClause {
33
+ when?: boolean;
34
+ sql: string;
35
+ bindings?: unknown[];
36
+ }
37
+
38
+ interface OrGroup {
39
+ when?: boolean;
40
+ or: WhereNode[];
41
+ }
42
+
43
+ interface AndGroup {
44
+ when?: boolean;
45
+ and: WhereNode[];
46
+ }
47
+
48
+ interface NotGroup {
49
+ when?: boolean;
50
+ not: WhereNode;
51
+ }
52
+
53
+ type WhereNode = Condition | RawClause | OrGroup | AndGroup | NotGroup;
54
+
55
+ interface OrderByItem {
56
+ column: string;
57
+ direction?: "asc" | "desc";
58
+ }
59
+
60
+ interface SelectManifest {
61
+ metadata: { name: string; module: string };
62
+ connection?: SqlConnectionResource;
63
+ transaction?: SqlTransactionResource;
64
+ from: string;
65
+ columns?: ColumnDef[];
66
+ distinct?: boolean;
67
+ distinctOn?: string[];
68
+ where?: WhereNode[];
69
+ groupBy?: string[];
70
+ having?: WhereNode[];
71
+ orderBy?: OrderByItem[];
72
+ limit?: unknown;
73
+ offset?: unknown;
74
+ inputType?: string | Record<string, any>;
75
+ }
76
+
77
+ // ── Controller ────────────────────────────────────────────────────────────────
78
+
79
+ class SqlSelectResource implements ResourceInstance {
80
+ constructor(
81
+ private readonly manifest: SelectManifest,
82
+ private readonly ctx: ResourceContext,
83
+ ) {}
84
+
85
+ async invoke(input: unknown): Promise<SqlResult> {
86
+ const m = this.manifest;
87
+ const ctx = this.ctx;
88
+ const inputs = {
89
+ ...extractDefaults(m.inputType, ctx),
90
+ ...((input as Record<string, unknown>) ?? {}),
91
+ };
92
+ const expandCtx = { inputs };
93
+
94
+ const where = ctx.expandValue(m.where ?? [], expandCtx) as WhereNode[];
95
+ const having = ctx.expandValue(m.having ?? [], expandCtx) as WhereNode[];
96
+ const limit = m.limit != null ? ctx.expandValue(m.limit, expandCtx) : undefined;
97
+ const offset = m.offset != null ? ctx.expandValue(m.offset, expandCtx) : undefined;
98
+
99
+ const connection = resolveSqlConnection(m.connection, ctx) ?? m.transaction?.getConnection();
100
+ if (!connection) {
101
+ throw new Error("Sql.Select: either 'connection' or 'transaction' must be set");
102
+ }
103
+
104
+ const { sql, params } = buildSelect(m, where, having, limit, offset, connection.driver);
105
+ const result = await connection.execute<Record<string, unknown>>(sql, params, m.transaction);
106
+ return { rows: result.rows, rowCount: result.rows.length };
107
+ }
108
+ }
109
+
110
+ // ── SQL building ──────────────────────────────────────────────────────────────
111
+
112
+ type Driver = "postgres" | "sqlite";
113
+
114
+ function buildSelect(
115
+ m: SelectManifest,
116
+ where: WhereNode[],
117
+ having: WhereNode[],
118
+ limit: unknown,
119
+ offset: unknown,
120
+ driver: Driver,
121
+ ): { sql: string; params: unknown[] } {
122
+ const params: unknown[] = [];
123
+ const addParam = (value: unknown): string => {
124
+ params.push(value);
125
+ return `$${params.length}`;
126
+ };
127
+
128
+ const parts: string[] = [];
129
+
130
+ // SELECT [DISTINCT [ON (...)]]
131
+ let selectClause = "SELECT";
132
+ if (m.distinct) {
133
+ selectClause += " DISTINCT";
134
+ } else if (m.distinctOn && m.distinctOn.length > 0) {
135
+ selectClause += ` DISTINCT ON (${m.distinctOn.map(quoteIdent).join(", ")})`;
136
+ }
137
+ const colList = m.columns && m.columns.length > 0 ? buildColumns(m.columns) : "*";
138
+ parts.push(`${selectClause} ${colList}`);
139
+
140
+ // FROM
141
+ parts.push(`FROM ${quoteIdent(m.from)}`);
142
+
143
+ // WHERE
144
+ const whereStr = buildClauses(where, "AND", driver, addParam);
145
+ if (whereStr) parts.push(`WHERE ${whereStr}`);
146
+
147
+ // GROUP BY
148
+ if (m.groupBy && m.groupBy.length > 0) {
149
+ parts.push(`GROUP BY ${m.groupBy.map(quoteIdent).join(", ")}`);
150
+ }
151
+
152
+ // HAVING
153
+ const havingStr = buildClauses(having, "AND", driver, addParam);
154
+ if (havingStr) parts.push(`HAVING ${havingStr}`);
155
+
156
+ // ORDER BY
157
+ if (m.orderBy && m.orderBy.length > 0) {
158
+ const orderParts = m.orderBy.map(
159
+ (o) => `${quoteIdent(o.column)} ${(o.direction ?? "asc").toUpperCase()}`,
160
+ );
161
+ parts.push(`ORDER BY ${orderParts.join(", ")}`);
162
+ }
163
+
164
+ // LIMIT / OFFSET
165
+ if (limit != null) parts.push(`LIMIT ${addParam(limit)}`);
166
+ if (offset != null) parts.push(`OFFSET ${addParam(offset)}`);
167
+
168
+ return { sql: parts.join("\n"), params };
169
+ }
170
+
171
+ function buildColumns(columns: ColumnDef[]): string {
172
+ return columns
173
+ .map((c) => {
174
+ if (typeof c === "string") return quoteIdent(c);
175
+ if ("expr" in c) return c.as ? `${c.expr} AS ${quoteIdent(c.as)}` : c.expr;
176
+ return c.as ? `${quoteIdent(c.column)} AS ${quoteIdent(c.as)}` : quoteIdent(c.column);
177
+ })
178
+ .join(", ");
179
+ }
180
+
181
+ function buildClauses(
182
+ clauses: WhereNode[],
183
+ join: "AND" | "OR",
184
+ driver: Driver,
185
+ addParam: (v: unknown) => string,
186
+ ): string | null {
187
+ const parts: string[] = [];
188
+ for (const clause of clauses) {
189
+ if (clause.when === false) continue;
190
+ const built = buildClause(clause, driver, addParam);
191
+ if (built !== null) parts.push(built);
192
+ }
193
+ if (parts.length === 0) return null;
194
+ if (parts.length === 1) return parts[0];
195
+ return parts.join(` ${join} `);
196
+ }
197
+
198
+ function buildClause(
199
+ node: WhereNode,
200
+ driver: Driver,
201
+ addParam: (v: unknown) => string,
202
+ ): string | null {
203
+ if ("not" in node) {
204
+ const inner = buildClause(node.not, driver, addParam);
205
+ return inner ? `NOT (${inner})` : null;
206
+ }
207
+ if ("or" in node) {
208
+ const inner = buildClauses(node.or, "OR", driver, addParam);
209
+ return inner ? `(${inner})` : null;
210
+ }
211
+ if ("and" in node) {
212
+ const inner = buildClauses(node.and, "AND", driver, addParam);
213
+ return inner ? `(${inner})` : null;
214
+ }
215
+ if ("sql" in node) {
216
+ return renumberFragment(node.sql, node.bindings ?? [], addParam);
217
+ }
218
+ if ("column" in node) {
219
+ return buildCondition(node, driver, addParam);
220
+ }
221
+ return null;
222
+ }
223
+
224
+ function buildCondition(c: Condition, driver: Driver, addParam: (v: unknown) => string): string {
225
+ const col = quoteIdent(c.column);
226
+ switch (c.op) {
227
+ case "is_null":
228
+ return `${col} IS NULL`;
229
+ case "is_not_null":
230
+ return `${col} IS NOT NULL`;
231
+ case "in": {
232
+ if (driver === "postgres") {
233
+ return `${col} = ANY(${addParam(c.value)})`;
234
+ }
235
+ const placeholders = (c.value as unknown[]).map((v) => addParam(v)).join(", ");
236
+ return `${col} IN (${placeholders})`;
237
+ }
238
+ default: {
239
+ const rhs = c.ref !== undefined ? quoteIdent(c.ref) : addParam(c.value);
240
+ return `${col} ${opToSql(c.op)} ${rhs}`;
241
+ }
242
+ }
243
+ }
244
+
245
+ function renumberFragment(
246
+ sql: string,
247
+ bindings: unknown[],
248
+ addParam: (v: unknown) => string,
249
+ ): string {
250
+ return sql.replace(/\$(\d+)/g, (_, idx) => addParam(bindings[Number(idx) - 1]));
251
+ }
252
+
253
+ function quoteIdent(name: string): string {
254
+ return `"${name.replace(/"/g, '""')}"`;
255
+ }
256
+
257
+ function opToSql(op: Op): string {
258
+ const map: Record<string, string> = {
259
+ eq: "=",
260
+ ne: "<>",
261
+ lt: "<",
262
+ lte: "<=",
263
+ gt: ">",
264
+ gte: ">=",
265
+ like: "LIKE",
266
+ ilike: "ILIKE",
267
+ };
268
+ const sql = map[op];
269
+ if (!sql) throw new Error(`Sql.Select: unknown operator '${op}'`);
270
+ return sql;
271
+ }
272
+
273
+ // ── Exports ───────────────────────────────────────────────────────────────────
274
+
275
+ function extractDefaults(
276
+ inputType: string | Record<string, any> | undefined,
277
+ ctx: ResourceContext,
278
+ ): Record<string, unknown> {
279
+ if (!inputType) return {};
280
+
281
+ // Resolve schema: string ref → look up, inline object → use directly
282
+ let schema: Record<string, any> | undefined;
283
+ if (typeof inputType === "string") {
284
+ schema = ctx.lookupSchema(inputType) as Record<string, any> | undefined;
285
+ } else if (inputType.schema && typeof inputType.schema === "object") {
286
+ schema = inputType.schema;
287
+ } else {
288
+ schema = inputType;
289
+ }
290
+
291
+ if (!schema || typeof schema !== "object") return {};
292
+ const props = schema.properties as Record<string, any> | undefined;
293
+ if (!props) return {};
294
+
295
+ const defaults: Record<string, unknown> = {};
296
+ for (const [key, def] of Object.entries(props)) {
297
+ if (def && typeof def === "object" && "default" in def) {
298
+ defaults[key] = def.default;
299
+ }
300
+ }
301
+ return defaults;
302
+ }
303
+
304
+ export function register(): void {}
305
+
306
+ export async function create(
307
+ resource: SelectManifest,
308
+ ctx: ResourceContext,
309
+ ): Promise<SqlSelectResource> {
310
+ return new SqlSelectResource(resource, ctx);
311
+ }
@@ -0,0 +1,62 @@
1
+ import type { Invocable, ResourceContext, ResourceInstance } from "@telorun/sdk";
2
+ import type { SqlConnectionResource } from "./sql-connection-controller.js";
3
+ import { resolveSqlConnection } from "./sql-connection-ref.js";
4
+ import { currentTxId } from "./transaction-store.js";
5
+
6
+ interface SqlTransactionManifest {
7
+ metadata: { name: string; module: string };
8
+ connection: SqlConnectionResource;
9
+ steps: Invocable;
10
+ inputs?: Record<string, unknown>;
11
+ }
12
+
13
+ export class SqlTransactionResource implements ResourceInstance {
14
+ constructor(
15
+ private readonly manifest: SqlTransactionManifest,
16
+ private readonly ctx: ResourceContext,
17
+ ) {}
18
+
19
+ getConnection(): SqlConnectionResource {
20
+ return (
21
+ resolveSqlConnection(this.manifest.connection, this.ctx) ??
22
+ failMissingConnection(this.manifest.metadata.name)
23
+ );
24
+ }
25
+
26
+ assertActive(): void {
27
+ if (!currentTxId()) {
28
+ throw new Error(
29
+ `Sql.Transaction '${this.manifest.metadata.name}': used outside an active transaction`,
30
+ );
31
+ }
32
+ }
33
+
34
+ async invoke(input: unknown): Promise<unknown> {
35
+ const m = this.manifest;
36
+ const ctx = this.ctx;
37
+
38
+ // Flat nesting: if already inside a transaction, reuse it
39
+ if (currentTxId()) {
40
+ const expandedInputs = ctx.expandValue(m.inputs ?? {}, input ?? {});
41
+ return m.steps.invoke(expandedInputs);
42
+ }
43
+
44
+ const conn = this.getConnection();
45
+ const expandedInputs = ctx.expandValue(m.inputs ?? {}, input ?? {});
46
+
47
+ return conn.transaction(() => m.steps.invoke(expandedInputs));
48
+ }
49
+ }
50
+
51
+ function failMissingConnection(name: string): never {
52
+ throw new Error(`Sql.Transaction '${name}': missing connection`);
53
+ }
54
+
55
+ export function register(): void {}
56
+
57
+ export async function create(
58
+ resource: SqlTransactionManifest,
59
+ ctx: ResourceContext,
60
+ ): Promise<SqlTransactionResource> {
61
+ return new SqlTransactionResource(resource, ctx);
62
+ }
@@ -0,0 +1,34 @@
1
+ import { Database } from "bun:sqlite";
2
+ import type { SqliteDb } from "./sqlite-driver-interface.js";
3
+
4
+ export function openDatabase(file: string): SqliteDb {
5
+ const db = new Database(file);
6
+
7
+ return {
8
+ prepare(sql: string) {
9
+ const stmt = db.prepare(sql);
10
+ return {
11
+ reader: true,
12
+ all(params: ReadonlyArray<unknown>) {
13
+ return stmt.all(...(params as any[]));
14
+ },
15
+ run(params: ReadonlyArray<unknown>) {
16
+ const result = stmt.run(...(params as any[]));
17
+ return {
18
+ changes: result.changes,
19
+ lastInsertRowid: result.lastInsertRowid,
20
+ };
21
+ },
22
+ iterate(params: ReadonlyArray<unknown>) {
23
+ return stmt.iterate(...(params as any[])) as IterableIterator<unknown>;
24
+ },
25
+ };
26
+ },
27
+ exec(sql: string) {
28
+ db.exec(sql);
29
+ },
30
+ close() {
31
+ db.close();
32
+ },
33
+ };
34
+ }
@@ -0,0 +1,15 @@
1
+ export interface SqliteStatement {
2
+ readonly reader: boolean;
3
+ all(params: ReadonlyArray<unknown>): unknown[];
4
+ run(params: ReadonlyArray<unknown>): {
5
+ changes: number | bigint;
6
+ lastInsertRowid: number | bigint;
7
+ };
8
+ iterate(params: ReadonlyArray<unknown>): IterableIterator<unknown>;
9
+ }
10
+
11
+ export interface SqliteDb {
12
+ prepare(sql: string): SqliteStatement;
13
+ exec(sql: string): void;
14
+ close(): void;
15
+ }
@@ -0,0 +1,35 @@
1
+ import Database from "better-sqlite3";
2
+ import type { SqliteDb } from "./sqlite-driver-interface.js";
3
+
4
+ export function openDatabase(file: string): SqliteDb {
5
+ const db = new Database(file);
6
+
7
+ return {
8
+ prepare(sql: string) {
9
+ const stmt = db.prepare(sql);
10
+
11
+ return {
12
+ reader: stmt.reader,
13
+ all(params: ReadonlyArray<unknown>) {
14
+ return stmt.all(...(params as unknown[]));
15
+ },
16
+ run(params: ReadonlyArray<unknown>) {
17
+ const result = stmt.run(...(params as unknown[]));
18
+ return {
19
+ changes: result.changes,
20
+ lastInsertRowid: result.lastInsertRowid,
21
+ };
22
+ },
23
+ iterate(params: ReadonlyArray<unknown>) {
24
+ return stmt.iterate(...(params as unknown[])) as IterableIterator<unknown>;
25
+ },
26
+ };
27
+ },
28
+ exec(sql: string) {
29
+ db.exec(sql);
30
+ },
31
+ close() {
32
+ db.close();
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,20 @@
1
+ import { AsyncLocalStorage } from "async_hooks";
2
+
3
+ export interface TxEntry {
4
+ executor: unknown;
5
+ }
6
+
7
+ const txMap = new Map<string, TxEntry>();
8
+ export const txStorage = new AsyncLocalStorage<string>();
9
+
10
+ export const setTx = (id: string, entry: TxEntry): void => {
11
+ txMap.set(id, entry);
12
+ };
13
+
14
+ export const getTx = (id: string): TxEntry | undefined => txMap.get(id);
15
+
16
+ export const deleteTx = (id: string): void => {
17
+ txMap.delete(id);
18
+ };
19
+
20
+ export const currentTxId = (): string | undefined => txStorage.getStore();