@telorun/sql 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/sql-command-controller.d.ts +2 -2
- package/dist/sql-command-controller.js +7 -2
- package/dist/sql-connection-base.d.ts +20 -5
- package/dist/sql-connection-base.js +71 -30
- package/dist/sql-connection.d.ts +16 -5
- package/dist/sql-migrations-controller.js +23 -1
- package/dist/sql-query-controller.d.ts +2 -2
- package/dist/sql-query-controller.js +5 -2
- package/dist/sql-run.d.ts +2 -3
- package/dist/sql-run.js +4 -4
- package/dist/sql-selection-controller.d.ts +2 -2
- package/dist/sql-selection-controller.js +5 -2
- package/dist/sql-transaction-controller.d.ts +4 -4
- package/dist/sql-transaction-controller.js +45 -13
- package/package.json +2 -2
- package/src/sql-command-controller.ts +8 -3
- package/src/sql-connection-base.ts +82 -31
- package/src/sql-connection.ts +22 -5
- package/src/sql-migrations-controller.ts +26 -1
- package/src/sql-query-controller.ts +6 -3
- package/src/sql-run.ts +13 -5
- package/src/sql-selection-controller.ts +6 -3
- package/src/sql-transaction-controller.ts +68 -19
- package/dist/transaction-store.d.ts +0 -9
- package/dist/transaction-store.js +0 -11
- package/src/transaction-store.ts +0 -20
package/README.md
CHANGED
|
@@ -23,9 +23,9 @@ Built to be language-agnostic and infinitely extensible.
|
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
25
|
# Reconcile your manifest into a running backend
|
|
26
|
-
$ telo ./examples/
|
|
26
|
+
$ telo ./examples/todo-app
|
|
27
27
|
|
|
28
|
-
{"level":30,"time":1771610393008,"pid":1310178,"hostname":"dev","msg":"Server listening at http://127.0.0.1:
|
|
28
|
+
{"level":30,"time":1771610393008,"pid":1310178,"hostname":"dev","msg":"Server listening at http://127.0.0.1:8077"}
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
## Why use Telo?
|
|
@@ -48,7 +48,9 @@ See [examples/](./examples/) for a list of working applications.
|
|
|
48
48
|
|
|
49
49
|
## Status
|
|
50
50
|
|
|
51
|
-
Telo is under
|
|
51
|
+
Telo is under heavy development. While it is pre-1.0, breaking changes ship in **minor** releases — manifest shapes, kind schemas, and APIs can change between versions. Pin your imports and expect to update manifests when you upgrade. The core runtime, module system, and standard library are functional, but Telo is not yet recommended for production use.
|
|
52
|
+
|
|
53
|
+
**1.0 lands when the Rust kernel reaches feature parity with the Node.js implementation** — that is the milestone that freezes the manifest contract across runtimes.
|
|
52
54
|
|
|
53
55
|
## The Meaning of Telo
|
|
54
56
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
1
|
+
import type { InvokeContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
2
|
import type { SqlConnection } from "./sql-connection.js";
|
|
3
3
|
import type { SqlResult } from "./sql-query-controller.js";
|
|
4
4
|
import type { SqlTransactionResource } from "./sql-transaction-controller.js";
|
|
@@ -18,7 +18,7 @@ declare class SqlCommandResource implements ResourceInstance {
|
|
|
18
18
|
private readonly manifest;
|
|
19
19
|
private readonly ctx;
|
|
20
20
|
constructor(manifest: SqlCommandManifest, ctx: ResourceContext);
|
|
21
|
-
invoke(input: unknown): Promise<SqlResult>;
|
|
21
|
+
invoke(input: unknown, invokeCtx?: InvokeContext): Promise<SqlResult>;
|
|
22
22
|
}
|
|
23
23
|
export declare function register(): void;
|
|
24
24
|
export declare function create(resource: SqlCommandManifest, ctx: ResourceContext): Promise<SqlCommandResource>;
|
|
@@ -7,7 +7,7 @@ class SqlCommandResource {
|
|
|
7
7
|
this.manifest = manifest;
|
|
8
8
|
this.ctx = ctx;
|
|
9
9
|
}
|
|
10
|
-
async invoke(input) {
|
|
10
|
+
async invoke(input, invokeCtx) {
|
|
11
11
|
const m = this.manifest;
|
|
12
12
|
const ctx = this.ctx;
|
|
13
13
|
const connection = resolveSqlConnection(m.connection, ctx, () => `Sql.Command "${m.metadata.name}": 'connection'`) ??
|
|
@@ -15,7 +15,12 @@ class SqlCommandResource {
|
|
|
15
15
|
if (!connection) {
|
|
16
16
|
throw new Error("Sql: either 'connection' or 'transaction' must be set");
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
// ERR_ZONE_REQUIRED when no sql.Transaction zone is open on THIS statement's
|
|
19
|
+
// connection — a transaction on another connection no longer answers. The
|
|
20
|
+
// kernel supplies the zone kind and the correlation key (including the
|
|
21
|
+
// `/transaction/connection` fallback) from the `transaction` annotation.
|
|
22
|
+
const zone = m.transaction ? ctx.requireZone("transaction", invokeCtx) : undefined;
|
|
23
|
+
const result = await runSql(connection, zone, input, ctx, invokeCtx);
|
|
19
24
|
return { rows: result.rows, rowCount: connection.toRowCount(result) };
|
|
20
25
|
}
|
|
21
26
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { type InvokeContext, type ResourceContext, type ZoneEntry } from "@telorun/sdk";
|
|
1
2
|
import { type Kysely, type QueryResult } from "kysely";
|
|
2
3
|
import type { SqlConnection, SqlDialect } from "./sql-connection.js";
|
|
3
|
-
import type { SqlTransactionResource } from "./sql-transaction-controller.js";
|
|
4
4
|
/**
|
|
5
5
|
* The dialect-neutral half of a connection: statement execution, transaction
|
|
6
6
|
* scoping, template binding and row-count normalization over a kysely instance.
|
|
@@ -10,15 +10,30 @@ import type { SqlTransactionResource } from "./sql-transaction-controller.js";
|
|
|
10
10
|
* where the driver has a native multi-statement path.
|
|
11
11
|
*/
|
|
12
12
|
export declare abstract class SqlConnectionBase implements SqlConnection {
|
|
13
|
+
#private;
|
|
13
14
|
readonly dialect: SqlDialect;
|
|
15
|
+
protected readonly ctx: ResourceContext;
|
|
14
16
|
protected readonly db: Kysely<any>;
|
|
15
|
-
constructor(db: Kysely<any>, dialect: SqlDialect);
|
|
17
|
+
constructor(db: Kysely<any>, dialect: SqlDialect, ctx: ResourceContext);
|
|
16
18
|
get kysely(): Kysely<any>;
|
|
17
19
|
init(): Promise<void>;
|
|
18
20
|
teardown(): Promise<void>;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
runInTransaction<T>(body: (bind: (entry: ZoneEntry) => void) => Promise<T>): Promise<T>;
|
|
22
|
+
hasOpenTransaction(ctx?: InvokeContext): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Every statement this connection runs funnels through here — `executeTemplate`
|
|
25
|
+
* and `executeScript` both delegate — so it is the single instrumentation point.
|
|
26
|
+
*
|
|
27
|
+
* `db.query.text` is the statement, never the parameters: the values ARE the
|
|
28
|
+
* data, and a record carrying them would put row contents in the log. The
|
|
29
|
+
* statement itself is safe for a parameterized query (it is the template), and
|
|
30
|
+
* is `debug`-only regardless, because `Sql.Command` can carry inline literals.
|
|
31
|
+
*
|
|
32
|
+
* The disabled path allocates nothing and takes no clock reading — a query is
|
|
33
|
+
* the hottest thing this module does.
|
|
34
|
+
*/
|
|
35
|
+
execute<T>(sql: string, params?: unknown[], zone?: ZoneEntry, ctx?: InvokeContext): Promise<QueryResult<T>>;
|
|
36
|
+
executeTemplate<T>(fragments: string[], values: unknown[], zone?: ZoneEntry, ctx?: InvokeContext): Promise<QueryResult<T>>;
|
|
22
37
|
/** Hand the whole script to the driver as one statement. Backends whose driver
|
|
23
38
|
* needs a dedicated multi-statement entry point override this. */
|
|
24
39
|
executeScript(sql: string): Promise<void>;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { InvokeError, SEVERITY, } from "@telorun/sdk";
|
|
2
2
|
import { CompiledQuery } from "kysely";
|
|
3
|
-
import { currentTxId, deleteTx, getTx, setTx, txStorage } from "./transaction-store.js";
|
|
4
3
|
/**
|
|
5
4
|
* The dialect-neutral half of a connection: statement execution, transaction
|
|
6
5
|
* scoping, template binding and row-count normalization over a kysely instance.
|
|
@@ -11,9 +10,17 @@ import { currentTxId, deleteTx, getTx, setTx, txStorage } from "./transaction-st
|
|
|
11
10
|
*/
|
|
12
11
|
export class SqlConnectionBase {
|
|
13
12
|
dialect;
|
|
13
|
+
ctx;
|
|
14
14
|
db;
|
|
15
|
-
|
|
15
|
+
/** Executors for transaction zones open on THIS connection. An instance
|
|
16
|
+
* field, never a module global: the transaction controller and this base are
|
|
17
|
+
* delivered as different bundles, each inlining its own copy of a shared
|
|
18
|
+
* source file, so module scope would be one map per bundle and every lookup
|
|
19
|
+
* a miss (the payload rule, kernel/specs/execution-zones.md §8). */
|
|
20
|
+
#executors = new WeakMap();
|
|
21
|
+
constructor(db, dialect, ctx) {
|
|
16
22
|
this.dialect = dialect;
|
|
23
|
+
this.ctx = ctx;
|
|
17
24
|
this.db = db;
|
|
18
25
|
}
|
|
19
26
|
get kysely() {
|
|
@@ -27,28 +34,63 @@ export class SqlConnectionBase {
|
|
|
27
34
|
async teardown() {
|
|
28
35
|
await this.db.destroy();
|
|
29
36
|
}
|
|
30
|
-
async
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
async runInTransaction(body) {
|
|
38
|
+
this.ctx.log.debug("Transaction started");
|
|
39
|
+
try {
|
|
40
|
+
const result = await this.db
|
|
41
|
+
.transaction()
|
|
42
|
+
.execute((trx) => body((entry) => this.#executors.set(entry, trx)));
|
|
43
|
+
this.ctx.log.debug("Transaction committed");
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
// The error reaches the caller, but the ROLLBACK does not: a caller that
|
|
48
|
+
// maps the failure to a response sees nothing saying its writes were
|
|
49
|
+
// discarded, and that is the fact worth reconstructing afterwards.
|
|
50
|
+
this.ctx.log.debug("Transaction rolled back", undefined, { error: err });
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
hasOpenTransaction(ctx) {
|
|
55
|
+
return this.ctx.zonesFor(this, ctx).some((entry) => this.#executors.has(entry));
|
|
41
56
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Every statement this connection runs funnels through here — `executeTemplate`
|
|
59
|
+
* and `executeScript` both delegate — so it is the single instrumentation point.
|
|
60
|
+
*
|
|
61
|
+
* `db.query.text` is the statement, never the parameters: the values ARE the
|
|
62
|
+
* data, and a record carrying them would put row contents in the log. The
|
|
63
|
+
* statement itself is safe for a parameterized query (it is the template), and
|
|
64
|
+
* is `debug`-only regardless, because `Sql.Command` can carry inline literals.
|
|
65
|
+
*
|
|
66
|
+
* The disabled path allocates nothing and takes no clock reading — a query is
|
|
67
|
+
* the hottest thing this module does.
|
|
68
|
+
*/
|
|
69
|
+
async execute(sql, params = [], zone, ctx) {
|
|
70
|
+
const executor = this.resolveExecutor(zone, ctx);
|
|
71
|
+
if (!this.ctx.log.enabled(SEVERITY.debug)) {
|
|
72
|
+
return executor.executeQuery(CompiledQuery.raw(sql, params));
|
|
73
|
+
}
|
|
74
|
+
const startedAt = Date.now();
|
|
75
|
+
const result = await executor.executeQuery(CompiledQuery.raw(sql, params));
|
|
76
|
+
this.ctx.log.debug("Statement executed", {
|
|
77
|
+
"db.query.text": sql,
|
|
78
|
+
"db.response.returned_rows": result.rows.length,
|
|
79
|
+
// OTel's own name for this quantity, in OTel's own unit: SECONDS, as a
|
|
80
|
+
// double. Metric names and attribute keys are separate namespaces, so
|
|
81
|
+
// reusing the name is safe — what would not be safe is the name with the
|
|
82
|
+
// wrong magnitude, which is why this is not milliseconds. Units live in
|
|
83
|
+
// the convention, never in the key.
|
|
84
|
+
"db.client.operation.duration": (Date.now() - startedAt) / 1000,
|
|
85
|
+
});
|
|
86
|
+
return result;
|
|
45
87
|
}
|
|
46
|
-
async executeTemplate(fragments, values,
|
|
88
|
+
async executeTemplate(fragments, values, zone, ctx) {
|
|
47
89
|
let sql = fragments[0] ?? "";
|
|
48
90
|
for (let i = 1; i < fragments.length; i++) {
|
|
49
91
|
sql += this.placeholder(i) + fragments[i];
|
|
50
92
|
}
|
|
51
|
-
return this.execute(sql, values,
|
|
93
|
+
return this.execute(sql, values, zone, ctx);
|
|
52
94
|
}
|
|
53
95
|
/** Hand the whole script to the driver as one statement. Backends whose driver
|
|
54
96
|
* needs a dedicated multi-statement entry point override this. */
|
|
@@ -67,17 +109,16 @@ export class SqlConnectionBase {
|
|
|
67
109
|
placeholder(index) {
|
|
68
110
|
return this.dialect.placeholderStyle === "numbered" ? `$${index}` : "?";
|
|
69
111
|
}
|
|
70
|
-
resolveExecutor(
|
|
71
|
-
if (
|
|
72
|
-
|
|
112
|
+
resolveExecutor(zone, ctx) {
|
|
113
|
+
if (zone && !this.#executors.has(zone)) {
|
|
114
|
+
// A zone this connection did not open cannot be silently ignored: the
|
|
115
|
+
// caller declared a requirement, and `?? this.db` here would execute it
|
|
116
|
+
// outside the transaction it asked for — silent non-transactional writes
|
|
117
|
+
// instead of a loud failure.
|
|
118
|
+
throw new InvokeError("ERR_SQL_ZONE_FOREIGN", `Sql: the ${zone.kind} zone provided by '${zone.provider.ref.name}' was not opened on ` +
|
|
119
|
+
`this connection — the statement would execute outside the transaction it names`);
|
|
73
120
|
}
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
const entry = getTx(txId);
|
|
77
|
-
if (entry) {
|
|
78
|
-
return entry.executor;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
return this.db;
|
|
121
|
+
const entry = zone ?? this.ctx.zonesFor(this, ctx).find((e) => this.#executors.has(e));
|
|
122
|
+
return (entry && this.#executors.get(entry)) ?? this.db;
|
|
82
123
|
}
|
|
83
124
|
}
|
package/dist/sql-connection.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import type { ResourceInstance } from "@telorun/sdk";
|
|
1
|
+
import type { InvokeContext, ResourceInstance, ZoneEntry } from "@telorun/sdk";
|
|
2
2
|
import type { Kysely, QueryResult } from "kysely";
|
|
3
|
-
import type { SqlTransactionResource } from "./sql-transaction-controller.js";
|
|
4
3
|
/** Native bind-placeholder syntax: SQLite binds anonymous `?`, PostgreSQL binds
|
|
5
4
|
* numbered `$1`, `$2`, … */
|
|
6
5
|
export type PlaceholderStyle = "qmark" | "numbered";
|
|
@@ -25,6 +24,12 @@ export declare function quoteAnsiIdentifier(name: string): string;
|
|
|
25
24
|
* against. Backends (`sql-postgres`, `sql-sqlite`) own their own implementation
|
|
26
25
|
* — usually by extending {@link SqlConnectionBase} — so nothing in this module
|
|
27
26
|
* knows which databases exist.
|
|
27
|
+
*
|
|
28
|
+
* Transaction membership is ambient, carried by the kernel's execution-zone
|
|
29
|
+
* stack (kernel/specs/execution-zones.md) and keyed per connection: a statement
|
|
30
|
+
* executes on an open transaction's executor when a transaction zone
|
|
31
|
+
* correlated on THIS connection is ambient, or when the caller passes the
|
|
32
|
+
* zone entry explicitly.
|
|
28
33
|
*/
|
|
29
34
|
export interface SqlConnection extends ResourceInstance {
|
|
30
35
|
readonly dialect: SqlDialect;
|
|
@@ -33,13 +38,19 @@ export interface SqlConnection extends ResourceInstance {
|
|
|
33
38
|
* must stay implementable by a driver kysely does not support; a consumer
|
|
34
39
|
* that needs it (`Sql.Migrations`) checks and fails with a clear message. */
|
|
35
40
|
readonly kysely?: Kysely<any>;
|
|
36
|
-
execute<T>(sql: string, params?: unknown[],
|
|
41
|
+
execute<T>(sql: string, params?: unknown[], zone?: ZoneEntry, ctx?: InvokeContext): Promise<QueryResult<T>>;
|
|
37
42
|
/** Assemble SQL from literal fragments by interleaving dialect-native
|
|
38
43
|
* placeholders, then bind `values` positionally. */
|
|
39
|
-
executeTemplate<T>(fragments: string[], values: unknown[],
|
|
44
|
+
executeTemplate<T>(fragments: string[], values: unknown[], zone?: ZoneEntry, ctx?: InvokeContext): Promise<QueryResult<T>>;
|
|
40
45
|
/** Run a multi-statement script. */
|
|
41
46
|
executeScript(sql: string): Promise<void>;
|
|
42
|
-
transaction
|
|
47
|
+
/** Open a database transaction and hand the caller a `bind` that keys the
|
|
48
|
+
* open executor on the zone entry the caller mints (via `ctx.withZone`).
|
|
49
|
+
* The executor stays private to this connection — the payload rule. */
|
|
50
|
+
runInTransaction<T>(body: (bind: (entry: ZoneEntry) => void) => Promise<T>, ctx?: InvokeContext): Promise<T>;
|
|
51
|
+
/** True when an ambient transaction zone correlated on this connection has an
|
|
52
|
+
* open executor here — the flat-nesting check `Sql.Transaction` reuses. */
|
|
53
|
+
hasOpenTransaction(ctx?: InvokeContext): boolean;
|
|
43
54
|
/** Rows affected by a write, normalized across drivers. */
|
|
44
55
|
toRowCount(result: QueryResult<unknown>): number;
|
|
45
56
|
}
|
|
@@ -61,10 +61,32 @@ class SqlMigrationsResource {
|
|
|
61
61
|
migrationTableName: "migrations",
|
|
62
62
|
migrationLockTableName: "migration_locks",
|
|
63
63
|
});
|
|
64
|
-
const { error } = await migrator.migrateToLatest();
|
|
64
|
+
const { error, results } = await migrator.migrateToLatest();
|
|
65
|
+
// A schema change is the least reversible thing an app does at boot, and the
|
|
66
|
+
// per-migration outcome was being discarded — so a run that applied four
|
|
67
|
+
// migrations and a run that found none to apply looked identical afterwards.
|
|
68
|
+
// `info`, because which migrations a deployment applied is the fact you go
|
|
69
|
+
// looking for when a schema is not what you expected.
|
|
70
|
+
// `sql.migration.name`, not `db.migration.name`: OTel owns `db.*` and defines
|
|
71
|
+
// no migration attribute, and §6.2 forbids inventing keys inside a namespace
|
|
72
|
+
// someone else governs.
|
|
73
|
+
for (const applied of results ?? []) {
|
|
74
|
+
if (applied.status === "Success") {
|
|
75
|
+
this.ctx.log.info("Migration applied", { "sql.migration.name": applied.migrationName });
|
|
76
|
+
}
|
|
77
|
+
else if (applied.status === "Error") {
|
|
78
|
+
// The cause rides on the record: this is the error-severity line an
|
|
79
|
+
// operator finds first, and the migration name alone cannot say what
|
|
80
|
+
// went wrong. `error` keeps the type, stack and cause chain (§4.2).
|
|
81
|
+
this.ctx.log.error("Migration failed", { "sql.migration.name": applied.migrationName }, { error });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
65
84
|
if (error) {
|
|
66
85
|
throw error;
|
|
67
86
|
}
|
|
87
|
+
if (!results?.length) {
|
|
88
|
+
this.ctx.log.debug("No pending migrations");
|
|
89
|
+
}
|
|
68
90
|
}
|
|
69
91
|
}
|
|
70
92
|
function failMissingConnection() {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
1
|
+
import type { InvokeContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
2
|
import type { SqlConnection } from "./sql-connection.js";
|
|
3
3
|
import type { SqlTransactionResource } from "./sql-transaction-controller.js";
|
|
4
4
|
interface SqlQueryManifest {
|
|
@@ -21,7 +21,7 @@ declare class SqlQueryResource implements ResourceInstance {
|
|
|
21
21
|
private readonly manifest;
|
|
22
22
|
private readonly ctx;
|
|
23
23
|
constructor(manifest: SqlQueryManifest, ctx: ResourceContext);
|
|
24
|
-
invoke(input: unknown): Promise<SqlResult>;
|
|
24
|
+
invoke(input: unknown, invokeCtx?: InvokeContext): Promise<SqlResult>;
|
|
25
25
|
}
|
|
26
26
|
export declare function register(): void;
|
|
27
27
|
export declare function create(resource: SqlQueryManifest, ctx: ResourceContext): Promise<SqlQueryResource>;
|
|
@@ -7,11 +7,14 @@ class SqlQueryResource {
|
|
|
7
7
|
this.manifest = manifest;
|
|
8
8
|
this.ctx = ctx;
|
|
9
9
|
}
|
|
10
|
-
async invoke(input) {
|
|
10
|
+
async invoke(input, invokeCtx) {
|
|
11
11
|
const m = this.manifest;
|
|
12
12
|
const ctx = this.ctx;
|
|
13
13
|
const connection = resolveConnection(m.connection, m.transaction, ctx, () => `Sql.Query "${m.metadata.name}": 'connection'`);
|
|
14
|
-
|
|
14
|
+
// ERR_ZONE_REQUIRED when no sql.Transaction zone is open on THIS query's
|
|
15
|
+
// connection — a transaction on another connection no longer answers.
|
|
16
|
+
const zone = m.transaction ? ctx.requireZone("transaction", invokeCtx) : undefined;
|
|
17
|
+
const result = await runSql(connection, zone, input, ctx, invokeCtx);
|
|
15
18
|
return { rows: result.rows, rowCount: result.rows.length };
|
|
16
19
|
}
|
|
17
20
|
}
|
package/dist/sql-run.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { type ResourceContext } from "@telorun/sdk";
|
|
1
|
+
import { type InvokeContext, type ResourceContext, type ZoneEntry } from "@telorun/sdk";
|
|
2
2
|
import type { QueryResult } from "kysely";
|
|
3
3
|
import type { SqlConnection } from "./sql-connection.js";
|
|
4
|
-
import type { SqlTransactionResource } from "./sql-transaction-controller.js";
|
|
5
4
|
/** Execute the `sql` input of a Query/Exec resource against `connection`.
|
|
6
5
|
*
|
|
7
6
|
* Two modes:
|
|
@@ -13,4 +12,4 @@ import type { SqlTransactionResource } from "./sql-transaction-controller.js";
|
|
|
13
12
|
*
|
|
14
13
|
* Mixing a `!sql` template with `bindings` is rejected. A plain string `sql`
|
|
15
14
|
* with neither is executed verbatim. */
|
|
16
|
-
export declare function runSql(connection: SqlConnection,
|
|
15
|
+
export declare function runSql(connection: SqlConnection, zone: ZoneEntry | undefined, input: unknown, ctx: ResourceContext, invokeCtx?: InvokeContext): Promise<QueryResult<Record<string, unknown>>>;
|
package/dist/sql-run.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { InvokeError, isParameterizedSql } from "@telorun/sdk";
|
|
1
|
+
import { InvokeError, isParameterizedSql, } from "@telorun/sdk";
|
|
2
2
|
/** Execute the `sql` input of a Query/Exec resource against `connection`.
|
|
3
3
|
*
|
|
4
4
|
* Two modes:
|
|
@@ -10,7 +10,7 @@ import { InvokeError, isParameterizedSql } from "@telorun/sdk";
|
|
|
10
10
|
*
|
|
11
11
|
* Mixing a `!sql` template with `bindings` is rejected. A plain string `sql`
|
|
12
12
|
* with neither is executed verbatim. */
|
|
13
|
-
export async function runSql(connection,
|
|
13
|
+
export async function runSql(connection, zone, input, ctx, invokeCtx) {
|
|
14
14
|
const expanded = ctx.expandValue(input, {});
|
|
15
15
|
const sql = expanded.sql;
|
|
16
16
|
const bindings = expanded.bindings;
|
|
@@ -21,7 +21,7 @@ export async function runSql(connection, transaction, input, ctx) {
|
|
|
21
21
|
"Use one or the other — `!sql` binds each inline value automatically; " +
|
|
22
22
|
"`bindings` is for hand-written ? / $n placeholders.");
|
|
23
23
|
}
|
|
24
|
-
return connection.executeTemplate(sql.fragments, sql.values,
|
|
24
|
+
return connection.executeTemplate(sql.fragments, sql.values, zone, invokeCtx);
|
|
25
25
|
}
|
|
26
|
-
return connection.execute(sql, hasBindings ? bindings : [],
|
|
26
|
+
return connection.execute(sql, hasBindings ? bindings : [], zone, invokeCtx);
|
|
27
27
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
1
|
+
import type { InvokeContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
2
|
import type { SqlConnection } from "./sql-connection.js";
|
|
3
3
|
import type { SqlResult } from "./sql-query-controller.js";
|
|
4
4
|
import type { SqlTransactionResource } from "./sql-transaction-controller.js";
|
|
@@ -62,7 +62,7 @@ declare class SqlSelectionResource implements ResourceInstance {
|
|
|
62
62
|
private readonly manifest;
|
|
63
63
|
private readonly ctx;
|
|
64
64
|
constructor(manifest: SelectManifest, ctx: ResourceContext);
|
|
65
|
-
invoke(input: unknown): Promise<SqlResult>;
|
|
65
|
+
invoke(input: unknown, invokeCtx?: InvokeContext): Promise<SqlResult>;
|
|
66
66
|
}
|
|
67
67
|
export declare function register(): void;
|
|
68
68
|
export declare function create(resource: SelectManifest, ctx: ResourceContext): Promise<SqlSelectionResource>;
|
|
@@ -7,7 +7,7 @@ class SqlSelectionResource {
|
|
|
7
7
|
this.manifest = manifest;
|
|
8
8
|
this.ctx = ctx;
|
|
9
9
|
}
|
|
10
|
-
async invoke(input) {
|
|
10
|
+
async invoke(input, invokeCtx) {
|
|
11
11
|
const m = this.manifest;
|
|
12
12
|
const ctx = this.ctx;
|
|
13
13
|
const inputs = {
|
|
@@ -24,8 +24,11 @@ class SqlSelectionResource {
|
|
|
24
24
|
if (!connection) {
|
|
25
25
|
throw new Error("Sql.Selection: either 'connection' or 'transaction' must be set");
|
|
26
26
|
}
|
|
27
|
+
// ERR_ZONE_REQUIRED when no sql.Transaction zone is open on THIS selection's
|
|
28
|
+
// connection — a transaction on another connection no longer answers.
|
|
29
|
+
const zone = m.transaction ? ctx.requireZone("transaction", invokeCtx) : undefined;
|
|
27
30
|
const { sql, params } = buildSelect(m, where, having, limit, offset, connection.dialect);
|
|
28
|
-
const result = await connection.execute(sql, params,
|
|
31
|
+
const result = await connection.execute(sql, params, zone, invokeCtx);
|
|
29
32
|
return { rows: result.rows, rowCount: result.rows.length };
|
|
30
33
|
}
|
|
31
34
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type InvokeContext, type ResourceContext, type ResourceInstance } from "@telorun/sdk";
|
|
2
2
|
import type { SqlConnection } from "./sql-connection.js";
|
|
3
3
|
interface SqlTransactionManifest {
|
|
4
4
|
metadata: {
|
|
@@ -6,7 +6,7 @@ interface SqlTransactionManifest {
|
|
|
6
6
|
module: string;
|
|
7
7
|
};
|
|
8
8
|
connection: SqlConnection;
|
|
9
|
-
steps:
|
|
9
|
+
steps: ResourceInstance;
|
|
10
10
|
inputs?: Record<string, unknown>;
|
|
11
11
|
}
|
|
12
12
|
export declare class SqlTransactionResource implements ResourceInstance {
|
|
@@ -14,8 +14,8 @@ export declare class SqlTransactionResource implements ResourceInstance {
|
|
|
14
14
|
private readonly ctx;
|
|
15
15
|
constructor(manifest: SqlTransactionManifest, ctx: ResourceContext);
|
|
16
16
|
getConnection(): SqlConnection;
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
invoke(input: unknown, invokeCtx?: InvokeContext): Promise<unknown>;
|
|
18
|
+
private dispatchSteps;
|
|
19
19
|
}
|
|
20
20
|
export declare function register(): void;
|
|
21
21
|
export declare function create(resource: SqlTransactionManifest, ctx: ResourceContext): Promise<SqlTransactionResource>;
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import { InvokeError, getRefIdentity, } from "@telorun/sdk";
|
|
1
2
|
import { resolveSqlConnection } from "./sql-connection-ref.js";
|
|
2
|
-
|
|
3
|
+
/** The steps slot is `Telo.Executable`: an instance with either entry point. */
|
|
4
|
+
function isExecutable(value) {
|
|
5
|
+
const v = value;
|
|
6
|
+
return typeof v?.invoke === "function" || typeof v?.run === "function";
|
|
7
|
+
}
|
|
3
8
|
export class SqlTransactionResource {
|
|
4
9
|
manifest;
|
|
5
10
|
ctx;
|
|
@@ -10,22 +15,49 @@ export class SqlTransactionResource {
|
|
|
10
15
|
getConnection() {
|
|
11
16
|
return (resolveSqlConnection(this.manifest.connection, this.ctx, () => `Sql.Transaction "${this.manifest.metadata.name}": 'connection'`) ?? failMissingConnection(this.manifest.metadata.name));
|
|
12
17
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
async invoke(input, invokeCtx) {
|
|
19
|
+
const m = this.manifest;
|
|
20
|
+
const ctx = this.ctx;
|
|
21
|
+
// The declared `inputs:` map's CEL reads the caller's invocation input as
|
|
22
|
+
// `inputs.<field>` — the same variable name a Run.Sequence step's inputs
|
|
23
|
+
// read, and what the slot's `inputs: /inputs` pointer names.
|
|
24
|
+
const celScope = { inputs: input ?? {} };
|
|
25
|
+
const expandedInputs = ctx.expandValue(m.inputs ?? {}, celScope);
|
|
26
|
+
// `invokeCtx` is threaded rather than left to the ambient throughout: the
|
|
27
|
+
// caller's context is the authority on which zones are open, and a runtime
|
|
28
|
+
// with no ambient store has nothing else to read.
|
|
29
|
+
//
|
|
30
|
+
// Flat nesting: an ambient transaction on the SAME connection is joined; a
|
|
31
|
+
// different connection's transaction is not ours and we open our own.
|
|
32
|
+
const conn = this.getConnection();
|
|
33
|
+
if (conn.hasOpenTransaction(invokeCtx)) {
|
|
34
|
+
return this.dispatchSteps(expandedInputs, invokeCtx);
|
|
16
35
|
}
|
|
36
|
+
return conn.runInTransaction((bind) =>
|
|
37
|
+
// "steps" is this kind's own slot; the kernel reads `x-telo-provides-zone`
|
|
38
|
+
// there for the zone kind (this kind) and the correlation key
|
|
39
|
+
// (`/connection`). The entry is handed to the connection's own map —
|
|
40
|
+
// across the bundle/npm delivery split — and the derived context is
|
|
41
|
+
// threaded into the body dispatch, the discipline cancellation has.
|
|
42
|
+
ctx.withZone("steps", (zoneCtx, entry) => {
|
|
43
|
+
bind(entry);
|
|
44
|
+
return this.dispatchSteps(expandedInputs, zoneCtx);
|
|
45
|
+
}, invokeCtx));
|
|
17
46
|
}
|
|
18
|
-
|
|
47
|
+
dispatchSteps(inputs, zoneCtx) {
|
|
19
48
|
const m = this.manifest;
|
|
20
|
-
const
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
49
|
+
const target = this.ctx.resolveRef(m.steps, isExecutable, () => `Sql.Transaction "${m.metadata.name}": 'steps'`, "Telo.Executable");
|
|
50
|
+
// The kernel stamps `!ref` identity at Phase-5 injection, and `resolveRef`
|
|
51
|
+
// rescues the sentinel form, so a resolved target always carries one.
|
|
52
|
+
// Guessing a label instead would emit malformed dispatch events (`.Invoked`
|
|
53
|
+
// with no kind) — a silent wrong answer where a missing identity means the
|
|
54
|
+
// resolution path changed under us.
|
|
55
|
+
const id = getRefIdentity(target);
|
|
56
|
+
if (!id) {
|
|
57
|
+
throw new InvokeError("ERR_SQL_STEPS_UNIDENTIFIED", `Sql.Transaction '${m.metadata.name}': the resolved 'steps' target carries no reference ` +
|
|
58
|
+
`identity, so its dispatch cannot be traced or named`);
|
|
25
59
|
}
|
|
26
|
-
|
|
27
|
-
const expandedInputs = ctx.expandValue(m.inputs ?? {}, input ?? {});
|
|
28
|
-
return conn.transaction(() => m.steps.invoke(expandedInputs));
|
|
60
|
+
return this.ctx.invokeResolved(id.kind, id.name, target, inputs, zoneCtx);
|
|
29
61
|
}
|
|
30
62
|
}
|
|
31
63
|
function failMissingConnection(name) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/sql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Telo SQL module - SQL database resource kinds for Telo manifests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"@types/node": "^20.0.0",
|
|
44
44
|
"esbuild": "^0.25.12",
|
|
45
45
|
"typescript": "^5.0.0",
|
|
46
|
-
"@telorun/sdk": "0.
|
|
46
|
+
"@telorun/sdk": "0.70.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"@telorun/sdk": "*"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
1
|
+
import type { InvokeContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
2
|
import type { SqlConnection } from "./sql-connection.js";
|
|
3
3
|
import { resolveSqlConnection } from "./sql-connection-ref.js";
|
|
4
4
|
import type { SqlResult } from "./sql-query-controller.js";
|
|
@@ -21,7 +21,7 @@ class SqlCommandResource implements ResourceInstance {
|
|
|
21
21
|
private readonly ctx: ResourceContext,
|
|
22
22
|
) {}
|
|
23
23
|
|
|
24
|
-
async invoke(input: unknown): Promise<SqlResult> {
|
|
24
|
+
async invoke(input: unknown, invokeCtx?: InvokeContext): Promise<SqlResult> {
|
|
25
25
|
const m = this.manifest;
|
|
26
26
|
const ctx = this.ctx;
|
|
27
27
|
|
|
@@ -32,7 +32,12 @@ class SqlCommandResource implements ResourceInstance {
|
|
|
32
32
|
throw new Error("Sql: either 'connection' or 'transaction' must be set");
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
// ERR_ZONE_REQUIRED when no sql.Transaction zone is open on THIS statement's
|
|
36
|
+
// connection — a transaction on another connection no longer answers. The
|
|
37
|
+
// kernel supplies the zone kind and the correlation key (including the
|
|
38
|
+
// `/transaction/connection` fallback) from the `transaction` annotation.
|
|
39
|
+
const zone = m.transaction ? ctx.requireZone("transaction", invokeCtx) : undefined;
|
|
40
|
+
const result = await runSql(connection, zone, input, ctx, invokeCtx);
|
|
36
41
|
return { rows: result.rows, rowCount: connection.toRowCount(result) };
|
|
37
42
|
}
|
|
38
43
|
}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
InvokeError,
|
|
3
|
+
SEVERITY,
|
|
4
|
+
type InvokeContext,
|
|
5
|
+
type ResourceContext,
|
|
6
|
+
type ZoneEntry,
|
|
7
|
+
} from "@telorun/sdk";
|
|
2
8
|
import { CompiledQuery, type Kysely, type QueryResult, type Transaction } from "kysely";
|
|
3
9
|
import type { SqlConnection, SqlDialect } from "./sql-connection.js";
|
|
4
|
-
import type { SqlTransactionResource } from "./sql-transaction-controller.js";
|
|
5
|
-
import { currentTxId, deleteTx, getTx, setTx, txStorage } from "./transaction-store.js";
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
12
|
* The dialect-neutral half of a connection: statement execution, transaction
|
|
@@ -15,9 +19,17 @@ import { currentTxId, deleteTx, getTx, setTx, txStorage } from "./transaction-st
|
|
|
15
19
|
export abstract class SqlConnectionBase implements SqlConnection {
|
|
16
20
|
protected readonly db: Kysely<any>;
|
|
17
21
|
|
|
22
|
+
/** Executors for transaction zones open on THIS connection. An instance
|
|
23
|
+
* field, never a module global: the transaction controller and this base are
|
|
24
|
+
* delivered as different bundles, each inlining its own copy of a shared
|
|
25
|
+
* source file, so module scope would be one map per bundle and every lookup
|
|
26
|
+
* a miss (the payload rule, kernel/specs/execution-zones.md §8). */
|
|
27
|
+
readonly #executors = new WeakMap<ZoneEntry, Kysely<any>>();
|
|
28
|
+
|
|
18
29
|
constructor(
|
|
19
30
|
db: Kysely<any>,
|
|
20
31
|
readonly dialect: SqlDialect,
|
|
32
|
+
protected readonly ctx: ResourceContext,
|
|
21
33
|
) {
|
|
22
34
|
this.db = db;
|
|
23
35
|
}
|
|
@@ -36,38 +48,77 @@ export abstract class SqlConnectionBase implements SqlConnection {
|
|
|
36
48
|
await this.db.destroy();
|
|
37
49
|
}
|
|
38
50
|
|
|
39
|
-
async
|
|
40
|
-
|
|
51
|
+
async runInTransaction<T>(
|
|
52
|
+
body: (bind: (entry: ZoneEntry) => void) => Promise<T>,
|
|
53
|
+
): Promise<T> {
|
|
54
|
+
this.ctx.log.debug("Transaction started");
|
|
55
|
+
try {
|
|
56
|
+
const result = await this.db
|
|
57
|
+
.transaction()
|
|
58
|
+
.execute((trx: Transaction<any>) => body((entry) => this.#executors.set(entry, trx)));
|
|
59
|
+
this.ctx.log.debug("Transaction committed");
|
|
60
|
+
return result;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// The error reaches the caller, but the ROLLBACK does not: a caller that
|
|
63
|
+
// maps the failure to a response sees nothing saying its writes were
|
|
64
|
+
// discarded, and that is the fact worth reconstructing afterwards.
|
|
65
|
+
this.ctx.log.debug("Transaction rolled back", undefined, { error: err });
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
41
69
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
try {
|
|
45
|
-
return await txStorage.run(txId, cb);
|
|
46
|
-
} finally {
|
|
47
|
-
deleteTx(txId);
|
|
48
|
-
}
|
|
49
|
-
});
|
|
70
|
+
hasOpenTransaction(ctx?: InvokeContext): boolean {
|
|
71
|
+
return this.ctx.zonesFor(this, ctx).some((entry) => this.#executors.has(entry));
|
|
50
72
|
}
|
|
51
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Every statement this connection runs funnels through here — `executeTemplate`
|
|
76
|
+
* and `executeScript` both delegate — so it is the single instrumentation point.
|
|
77
|
+
*
|
|
78
|
+
* `db.query.text` is the statement, never the parameters: the values ARE the
|
|
79
|
+
* data, and a record carrying them would put row contents in the log. The
|
|
80
|
+
* statement itself is safe for a parameterized query (it is the template), and
|
|
81
|
+
* is `debug`-only regardless, because `Sql.Command` can carry inline literals.
|
|
82
|
+
*
|
|
83
|
+
* The disabled path allocates nothing and takes no clock reading — a query is
|
|
84
|
+
* the hottest thing this module does.
|
|
85
|
+
*/
|
|
52
86
|
async execute<T>(
|
|
53
87
|
sql: string,
|
|
54
88
|
params: unknown[] = [],
|
|
55
|
-
|
|
89
|
+
zone?: ZoneEntry,
|
|
90
|
+
ctx?: InvokeContext,
|
|
56
91
|
): Promise<QueryResult<T>> {
|
|
57
|
-
const executor = this.resolveExecutor(
|
|
58
|
-
|
|
92
|
+
const executor = this.resolveExecutor(zone, ctx);
|
|
93
|
+
if (!this.ctx.log.enabled(SEVERITY.debug)) {
|
|
94
|
+
return executor.executeQuery<T>(CompiledQuery.raw(sql, params));
|
|
95
|
+
}
|
|
96
|
+
const startedAt = Date.now();
|
|
97
|
+
const result = await executor.executeQuery<T>(CompiledQuery.raw(sql, params));
|
|
98
|
+
this.ctx.log.debug("Statement executed", {
|
|
99
|
+
"db.query.text": sql,
|
|
100
|
+
"db.response.returned_rows": result.rows.length,
|
|
101
|
+
// OTel's own name for this quantity, in OTel's own unit: SECONDS, as a
|
|
102
|
+
// double. Metric names and attribute keys are separate namespaces, so
|
|
103
|
+
// reusing the name is safe — what would not be safe is the name with the
|
|
104
|
+
// wrong magnitude, which is why this is not milliseconds. Units live in
|
|
105
|
+
// the convention, never in the key.
|
|
106
|
+
"db.client.operation.duration": (Date.now() - startedAt) / 1000,
|
|
107
|
+
});
|
|
108
|
+
return result;
|
|
59
109
|
}
|
|
60
110
|
|
|
61
111
|
async executeTemplate<T>(
|
|
62
112
|
fragments: string[],
|
|
63
113
|
values: unknown[],
|
|
64
|
-
|
|
114
|
+
zone?: ZoneEntry,
|
|
115
|
+
ctx?: InvokeContext,
|
|
65
116
|
): Promise<QueryResult<T>> {
|
|
66
117
|
let sql = fragments[0] ?? "";
|
|
67
118
|
for (let i = 1; i < fragments.length; i++) {
|
|
68
119
|
sql += this.placeholder(i) + fragments[i];
|
|
69
120
|
}
|
|
70
|
-
return this.execute<T>(sql, values,
|
|
121
|
+
return this.execute<T>(sql, values, zone, ctx);
|
|
71
122
|
}
|
|
72
123
|
|
|
73
124
|
/** Hand the whole script to the driver as one statement. Backends whose driver
|
|
@@ -92,19 +143,19 @@ export abstract class SqlConnectionBase implements SqlConnection {
|
|
|
92
143
|
return this.dialect.placeholderStyle === "numbered" ? `$${index}` : "?";
|
|
93
144
|
}
|
|
94
145
|
|
|
95
|
-
private resolveExecutor(
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
146
|
+
private resolveExecutor(zone?: ZoneEntry, ctx?: InvokeContext): Kysely<any> {
|
|
147
|
+
if (zone && !this.#executors.has(zone)) {
|
|
148
|
+
// A zone this connection did not open cannot be silently ignored: the
|
|
149
|
+
// caller declared a requirement, and `?? this.db` here would execute it
|
|
150
|
+
// outside the transaction it asked for — silent non-transactional writes
|
|
151
|
+
// instead of a loud failure.
|
|
152
|
+
throw new InvokeError(
|
|
153
|
+
"ERR_SQL_ZONE_FOREIGN",
|
|
154
|
+
`Sql: the ${zone.kind} zone provided by '${zone.provider.ref.name}' was not opened on ` +
|
|
155
|
+
`this connection — the statement would execute outside the transaction it names`,
|
|
156
|
+
);
|
|
106
157
|
}
|
|
107
|
-
|
|
108
|
-
return this.db;
|
|
158
|
+
const entry = zone ?? this.ctx.zonesFor(this, ctx).find((e) => this.#executors.has(e));
|
|
159
|
+
return (entry && this.#executors.get(entry)) ?? this.db;
|
|
109
160
|
}
|
|
110
161
|
}
|
package/src/sql-connection.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import type { ResourceInstance } from "@telorun/sdk";
|
|
1
|
+
import type { InvokeContext, ResourceInstance, ZoneEntry } from "@telorun/sdk";
|
|
2
2
|
import type { Kysely, QueryResult } from "kysely";
|
|
3
|
-
import type { SqlTransactionResource } from "./sql-transaction-controller.js";
|
|
4
3
|
|
|
5
4
|
/** Native bind-placeholder syntax: SQLite binds anonymous `?`, PostgreSQL binds
|
|
6
5
|
* numbered `$1`, `$2`, … */
|
|
@@ -37,6 +36,12 @@ export function quoteAnsiIdentifier(name: string): string {
|
|
|
37
36
|
* against. Backends (`sql-postgres`, `sql-sqlite`) own their own implementation
|
|
38
37
|
* — usually by extending {@link SqlConnectionBase} — so nothing in this module
|
|
39
38
|
* knows which databases exist.
|
|
39
|
+
*
|
|
40
|
+
* Transaction membership is ambient, carried by the kernel's execution-zone
|
|
41
|
+
* stack (kernel/specs/execution-zones.md) and keyed per connection: a statement
|
|
42
|
+
* executes on an open transaction's executor when a transaction zone
|
|
43
|
+
* correlated on THIS connection is ambient, or when the caller passes the
|
|
44
|
+
* zone entry explicitly.
|
|
40
45
|
*/
|
|
41
46
|
export interface SqlConnection extends ResourceInstance {
|
|
42
47
|
readonly dialect: SqlDialect;
|
|
@@ -50,7 +55,8 @@ export interface SqlConnection extends ResourceInstance {
|
|
|
50
55
|
execute<T>(
|
|
51
56
|
sql: string,
|
|
52
57
|
params?: unknown[],
|
|
53
|
-
|
|
58
|
+
zone?: ZoneEntry,
|
|
59
|
+
ctx?: InvokeContext,
|
|
54
60
|
): Promise<QueryResult<T>>;
|
|
55
61
|
|
|
56
62
|
/** Assemble SQL from literal fragments by interleaving dialect-native
|
|
@@ -58,13 +64,24 @@ export interface SqlConnection extends ResourceInstance {
|
|
|
58
64
|
executeTemplate<T>(
|
|
59
65
|
fragments: string[],
|
|
60
66
|
values: unknown[],
|
|
61
|
-
|
|
67
|
+
zone?: ZoneEntry,
|
|
68
|
+
ctx?: InvokeContext,
|
|
62
69
|
): Promise<QueryResult<T>>;
|
|
63
70
|
|
|
64
71
|
/** Run a multi-statement script. */
|
|
65
72
|
executeScript(sql: string): Promise<void>;
|
|
66
73
|
|
|
67
|
-
transaction
|
|
74
|
+
/** Open a database transaction and hand the caller a `bind` that keys the
|
|
75
|
+
* open executor on the zone entry the caller mints (via `ctx.withZone`).
|
|
76
|
+
* The executor stays private to this connection — the payload rule. */
|
|
77
|
+
runInTransaction<T>(
|
|
78
|
+
body: (bind: (entry: ZoneEntry) => void) => Promise<T>,
|
|
79
|
+
ctx?: InvokeContext,
|
|
80
|
+
): Promise<T>;
|
|
81
|
+
|
|
82
|
+
/** True when an ambient transaction zone correlated on this connection has an
|
|
83
|
+
* open executor here — the flat-nesting check `Sql.Transaction` reuses. */
|
|
84
|
+
hasOpenTransaction(ctx?: InvokeContext): boolean;
|
|
68
85
|
|
|
69
86
|
/** Rows affected by a write, normalized across drivers. */
|
|
70
87
|
toRowCount(result: QueryResult<unknown>): number;
|
|
@@ -97,10 +97,35 @@ class SqlMigrationsResource implements ResourceInstance {
|
|
|
97
97
|
migrationLockTableName: "migration_locks",
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
-
const { error } = await migrator.migrateToLatest();
|
|
100
|
+
const { error, results } = await migrator.migrateToLatest();
|
|
101
|
+
// A schema change is the least reversible thing an app does at boot, and the
|
|
102
|
+
// per-migration outcome was being discarded — so a run that applied four
|
|
103
|
+
// migrations and a run that found none to apply looked identical afterwards.
|
|
104
|
+
// `info`, because which migrations a deployment applied is the fact you go
|
|
105
|
+
// looking for when a schema is not what you expected.
|
|
106
|
+
// `sql.migration.name`, not `db.migration.name`: OTel owns `db.*` and defines
|
|
107
|
+
// no migration attribute, and §6.2 forbids inventing keys inside a namespace
|
|
108
|
+
// someone else governs.
|
|
109
|
+
for (const applied of results ?? []) {
|
|
110
|
+
if (applied.status === "Success") {
|
|
111
|
+
this.ctx.log.info("Migration applied", { "sql.migration.name": applied.migrationName });
|
|
112
|
+
} else if (applied.status === "Error") {
|
|
113
|
+
// The cause rides on the record: this is the error-severity line an
|
|
114
|
+
// operator finds first, and the migration name alone cannot say what
|
|
115
|
+
// went wrong. `error` keeps the type, stack and cause chain (§4.2).
|
|
116
|
+
this.ctx.log.error(
|
|
117
|
+
"Migration failed",
|
|
118
|
+
{ "sql.migration.name": applied.migrationName },
|
|
119
|
+
{ error },
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
101
123
|
if (error) {
|
|
102
124
|
throw error;
|
|
103
125
|
}
|
|
126
|
+
if (!results?.length) {
|
|
127
|
+
this.ctx.log.debug("No pending migrations");
|
|
128
|
+
}
|
|
104
129
|
}
|
|
105
130
|
}
|
|
106
131
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
1
|
+
import type { InvokeContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
2
|
import type { SqlConnection } from "./sql-connection.js";
|
|
3
3
|
import { resolveSqlConnection } from "./sql-connection-ref.js";
|
|
4
4
|
import { runSql } from "./sql-run.js";
|
|
@@ -25,7 +25,7 @@ class SqlQueryResource implements ResourceInstance {
|
|
|
25
25
|
private readonly ctx: ResourceContext,
|
|
26
26
|
) {}
|
|
27
27
|
|
|
28
|
-
async invoke(input: unknown): Promise<SqlResult> {
|
|
28
|
+
async invoke(input: unknown, invokeCtx?: InvokeContext): Promise<SqlResult> {
|
|
29
29
|
const m = this.manifest;
|
|
30
30
|
const ctx = this.ctx;
|
|
31
31
|
const connection = resolveConnection(
|
|
@@ -34,7 +34,10 @@ class SqlQueryResource implements ResourceInstance {
|
|
|
34
34
|
ctx,
|
|
35
35
|
() => `Sql.Query "${m.metadata.name}": 'connection'`,
|
|
36
36
|
);
|
|
37
|
-
|
|
37
|
+
// ERR_ZONE_REQUIRED when no sql.Transaction zone is open on THIS query's
|
|
38
|
+
// connection — a transaction on another connection no longer answers.
|
|
39
|
+
const zone = m.transaction ? ctx.requireZone("transaction", invokeCtx) : undefined;
|
|
40
|
+
const result = await runSql(connection, zone, input, ctx, invokeCtx);
|
|
38
41
|
return { rows: result.rows, rowCount: result.rows.length };
|
|
39
42
|
}
|
|
40
43
|
}
|
package/src/sql-run.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
InvokeError,
|
|
3
|
+
isParameterizedSql,
|
|
4
|
+
type InvokeContext,
|
|
5
|
+
type ResourceContext,
|
|
6
|
+
type ZoneEntry,
|
|
7
|
+
} from "@telorun/sdk";
|
|
2
8
|
import type { QueryResult } from "kysely";
|
|
3
9
|
import type { SqlConnection } from "./sql-connection.js";
|
|
4
|
-
import type { SqlTransactionResource } from "./sql-transaction-controller.js";
|
|
5
10
|
|
|
6
11
|
/** Execute the `sql` input of a Query/Exec resource against `connection`.
|
|
7
12
|
*
|
|
@@ -16,9 +21,10 @@ import type { SqlTransactionResource } from "./sql-transaction-controller.js";
|
|
|
16
21
|
* with neither is executed verbatim. */
|
|
17
22
|
export async function runSql(
|
|
18
23
|
connection: SqlConnection,
|
|
19
|
-
|
|
24
|
+
zone: ZoneEntry | undefined,
|
|
20
25
|
input: unknown,
|
|
21
26
|
ctx: ResourceContext,
|
|
27
|
+
invokeCtx?: InvokeContext,
|
|
22
28
|
): Promise<QueryResult<Record<string, unknown>>> {
|
|
23
29
|
const expanded = ctx.expandValue(input, {}) as { sql: unknown; bindings?: unknown[] };
|
|
24
30
|
const sql = expanded.sql;
|
|
@@ -37,13 +43,15 @@ export async function runSql(
|
|
|
37
43
|
return connection.executeTemplate<Record<string, unknown>>(
|
|
38
44
|
sql.fragments,
|
|
39
45
|
sql.values,
|
|
40
|
-
|
|
46
|
+
zone,
|
|
47
|
+
invokeCtx,
|
|
41
48
|
);
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
return connection.execute<Record<string, unknown>>(
|
|
45
52
|
sql as string,
|
|
46
53
|
hasBindings ? (bindings as unknown[]) : [],
|
|
47
|
-
|
|
54
|
+
zone,
|
|
55
|
+
invokeCtx,
|
|
48
56
|
);
|
|
49
57
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
1
|
+
import type { InvokeContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
2
|
import type { SqlConnection, SqlDialect } from "./sql-connection.js";
|
|
3
3
|
import { resolveSqlConnection } from "./sql-connection-ref.js";
|
|
4
4
|
import type { SqlResult } from "./sql-query-controller.js";
|
|
@@ -82,7 +82,7 @@ class SqlSelectionResource implements ResourceInstance {
|
|
|
82
82
|
private readonly ctx: ResourceContext,
|
|
83
83
|
) {}
|
|
84
84
|
|
|
85
|
-
async invoke(input: unknown): Promise<SqlResult> {
|
|
85
|
+
async invoke(input: unknown, invokeCtx?: InvokeContext): Promise<SqlResult> {
|
|
86
86
|
const m = this.manifest;
|
|
87
87
|
const ctx = this.ctx;
|
|
88
88
|
const inputs = {
|
|
@@ -103,8 +103,11 @@ class SqlSelectionResource implements ResourceInstance {
|
|
|
103
103
|
throw new Error("Sql.Selection: either 'connection' or 'transaction' must be set");
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// ERR_ZONE_REQUIRED when no sql.Transaction zone is open on THIS selection's
|
|
107
|
+
// connection — a transaction on another connection no longer answers.
|
|
108
|
+
const zone = m.transaction ? ctx.requireZone("transaction", invokeCtx) : undefined;
|
|
106
109
|
const { sql, params } = buildSelect(m, where, having, limit, offset, connection.dialect);
|
|
107
|
-
const result = await connection.execute<Record<string, unknown>>(sql, params,
|
|
110
|
+
const result = await connection.execute<Record<string, unknown>>(sql, params, zone, invokeCtx);
|
|
108
111
|
return { rows: result.rows, rowCount: result.rows.length };
|
|
109
112
|
}
|
|
110
113
|
}
|
|
@@ -1,15 +1,26 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
InvokeError,
|
|
3
|
+
getRefIdentity,
|
|
4
|
+
type InvokeContext,
|
|
5
|
+
type ResourceContext,
|
|
6
|
+
type ResourceInstance,
|
|
7
|
+
} from "@telorun/sdk";
|
|
2
8
|
import type { SqlConnection } from "./sql-connection.js";
|
|
3
9
|
import { resolveSqlConnection } from "./sql-connection-ref.js";
|
|
4
|
-
import { currentTxId } from "./transaction-store.js";
|
|
5
10
|
|
|
6
11
|
interface SqlTransactionManifest {
|
|
7
12
|
metadata: { name: string; module: string };
|
|
8
13
|
connection: SqlConnection;
|
|
9
|
-
steps:
|
|
14
|
+
steps: ResourceInstance;
|
|
10
15
|
inputs?: Record<string, unknown>;
|
|
11
16
|
}
|
|
12
17
|
|
|
18
|
+
/** The steps slot is `Telo.Executable`: an instance with either entry point. */
|
|
19
|
+
function isExecutable(value: unknown): value is ResourceInstance {
|
|
20
|
+
const v = value as ResourceInstance | undefined;
|
|
21
|
+
return typeof v?.invoke === "function" || typeof v?.run === "function";
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
export class SqlTransactionResource implements ResourceInstance {
|
|
14
25
|
constructor(
|
|
15
26
|
private readonly manifest: SqlTransactionManifest,
|
|
@@ -26,28 +37,66 @@ export class SqlTransactionResource implements ResourceInstance {
|
|
|
26
37
|
);
|
|
27
38
|
}
|
|
28
39
|
|
|
29
|
-
|
|
30
|
-
if (!currentTxId()) {
|
|
31
|
-
throw new Error(
|
|
32
|
-
`Sql.Transaction '${this.manifest.metadata.name}': used outside an active transaction`,
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async invoke(input: unknown): Promise<unknown> {
|
|
40
|
+
async invoke(input: unknown, invokeCtx?: InvokeContext): Promise<unknown> {
|
|
38
41
|
const m = this.manifest;
|
|
39
42
|
const ctx = this.ctx;
|
|
40
43
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
44
|
+
// The declared `inputs:` map's CEL reads the caller's invocation input as
|
|
45
|
+
// `inputs.<field>` — the same variable name a Run.Sequence step's inputs
|
|
46
|
+
// read, and what the slot's `inputs: /inputs` pointer names.
|
|
47
|
+
const celScope = { inputs: input ?? {} };
|
|
48
|
+
const expandedInputs = ctx.expandValue(m.inputs ?? {}, celScope) as Record<string, unknown>;
|
|
46
49
|
|
|
50
|
+
// `invokeCtx` is threaded rather than left to the ambient throughout: the
|
|
51
|
+
// caller's context is the authority on which zones are open, and a runtime
|
|
52
|
+
// with no ambient store has nothing else to read.
|
|
53
|
+
//
|
|
54
|
+
// Flat nesting: an ambient transaction on the SAME connection is joined; a
|
|
55
|
+
// different connection's transaction is not ours and we open our own.
|
|
47
56
|
const conn = this.getConnection();
|
|
48
|
-
|
|
57
|
+
if (conn.hasOpenTransaction(invokeCtx)) {
|
|
58
|
+
return this.dispatchSteps(expandedInputs, invokeCtx);
|
|
59
|
+
}
|
|
49
60
|
|
|
50
|
-
return conn.
|
|
61
|
+
return conn.runInTransaction((bind) =>
|
|
62
|
+
// "steps" is this kind's own slot; the kernel reads `x-telo-provides-zone`
|
|
63
|
+
// there for the zone kind (this kind) and the correlation key
|
|
64
|
+
// (`/connection`). The entry is handed to the connection's own map —
|
|
65
|
+
// across the bundle/npm delivery split — and the derived context is
|
|
66
|
+
// threaded into the body dispatch, the discipline cancellation has.
|
|
67
|
+
ctx.withZone(
|
|
68
|
+
"steps",
|
|
69
|
+
(zoneCtx, entry) => {
|
|
70
|
+
bind(entry);
|
|
71
|
+
return this.dispatchSteps(expandedInputs, zoneCtx);
|
|
72
|
+
},
|
|
73
|
+
invokeCtx,
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private dispatchSteps(inputs: Record<string, unknown>, zoneCtx?: InvokeContext): Promise<unknown> {
|
|
79
|
+
const m = this.manifest;
|
|
80
|
+
const target = this.ctx.resolveRef(
|
|
81
|
+
m.steps,
|
|
82
|
+
isExecutable,
|
|
83
|
+
() => `Sql.Transaction "${m.metadata.name}": 'steps'`,
|
|
84
|
+
"Telo.Executable",
|
|
85
|
+
);
|
|
86
|
+
// The kernel stamps `!ref` identity at Phase-5 injection, and `resolveRef`
|
|
87
|
+
// rescues the sentinel form, so a resolved target always carries one.
|
|
88
|
+
// Guessing a label instead would emit malformed dispatch events (`.Invoked`
|
|
89
|
+
// with no kind) — a silent wrong answer where a missing identity means the
|
|
90
|
+
// resolution path changed under us.
|
|
91
|
+
const id = getRefIdentity(target as object);
|
|
92
|
+
if (!id) {
|
|
93
|
+
throw new InvokeError(
|
|
94
|
+
"ERR_SQL_STEPS_UNIDENTIFIED",
|
|
95
|
+
`Sql.Transaction '${m.metadata.name}': the resolved 'steps' target carries no reference ` +
|
|
96
|
+
`identity, so its dispatch cannot be traced or named`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return this.ctx.invokeResolved(id.kind, id.name, target, inputs, zoneCtx);
|
|
51
100
|
}
|
|
52
101
|
}
|
|
53
102
|
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from "async_hooks";
|
|
2
|
-
export interface TxEntry {
|
|
3
|
-
executor: unknown;
|
|
4
|
-
}
|
|
5
|
-
export declare const txStorage: AsyncLocalStorage<string>;
|
|
6
|
-
export declare const setTx: (id: string, entry: TxEntry) => void;
|
|
7
|
-
export declare const getTx: (id: string) => TxEntry | undefined;
|
|
8
|
-
export declare const deleteTx: (id: string) => void;
|
|
9
|
-
export declare const currentTxId: () => string | undefined;
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from "async_hooks";
|
|
2
|
-
const txMap = new Map();
|
|
3
|
-
export const txStorage = new AsyncLocalStorage();
|
|
4
|
-
export const setTx = (id, entry) => {
|
|
5
|
-
txMap.set(id, entry);
|
|
6
|
-
};
|
|
7
|
-
export const getTx = (id) => txMap.get(id);
|
|
8
|
-
export const deleteTx = (id) => {
|
|
9
|
-
txMap.delete(id);
|
|
10
|
-
};
|
|
11
|
-
export const currentTxId = () => txStorage.getStore();
|
package/src/transaction-store.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
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();
|