@telorun/sql 0.1.1 → 0.1.3

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,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();