@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 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/hello-api
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:8844"}
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 **active development**. The core runtime, module system, and standard library are functional, but the API surface — including YAML shapes — may change without notice. Not yet recommended for production use.
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
- const result = await runSql(connection, m.transaction, input, ctx);
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
- transaction<T>(cb: () => Promise<T>): Promise<T>;
20
- execute<T>(sql: string, params?: unknown[], transaction?: SqlTransactionResource): Promise<QueryResult<T>>;
21
- executeTemplate<T>(fragments: string[], values: unknown[], transaction?: SqlTransactionResource): Promise<QueryResult<T>>;
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 { randomUUID } from "crypto";
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
- constructor(db, dialect) {
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 transaction(cb) {
31
- const txId = randomUUID();
32
- return this.db.transaction().execute(async (trx) => {
33
- setTx(txId, { executor: trx });
34
- try {
35
- return await txStorage.run(txId, cb);
36
- }
37
- finally {
38
- deleteTx(txId);
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
- async execute(sql, params = [], transaction) {
43
- const executor = this.resolveExecutor(transaction);
44
- return executor.executeQuery(CompiledQuery.raw(sql, params));
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, transaction) {
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, transaction);
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(transaction) {
71
- if (transaction) {
72
- transaction.assertActive();
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 txId = currentTxId();
75
- if (txId) {
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
  }
@@ -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[], transaction?: SqlTransactionResource): Promise<QueryResult<T>>;
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[], transaction?: SqlTransactionResource): Promise<QueryResult<T>>;
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<T>(cb: () => Promise<T>): Promise<T>;
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
- const result = await runSql(connection, m.transaction, input, ctx);
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, transaction: SqlTransactionResource | undefined, input: unknown, ctx: ResourceContext): Promise<QueryResult<Record<string, unknown>>>;
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, transaction, input, ctx) {
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, transaction);
24
+ return connection.executeTemplate(sql.fragments, sql.values, zone, invokeCtx);
25
25
  }
26
- return connection.execute(sql, hasBindings ? bindings : [], transaction);
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, m.transaction);
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 { Invocable, ResourceContext, ResourceInstance } from "@telorun/sdk";
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: Invocable;
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
- assertActive(): void;
18
- invoke(input: unknown): Promise<unknown>;
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
- import { currentTxId } from "./transaction-store.js";
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
- assertActive() {
14
- if (!currentTxId()) {
15
- throw new Error(`Sql.Transaction '${this.manifest.metadata.name}': used outside an active transaction`);
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
- async invoke(input) {
47
+ dispatchSteps(inputs, zoneCtx) {
19
48
  const m = this.manifest;
20
- const ctx = this.ctx;
21
- // Flat nesting: if already inside a transaction, reuse it
22
- if (currentTxId()) {
23
- const expandedInputs = ctx.expandValue(m.inputs ?? {}, input ?? {});
24
- return m.steps.invoke(expandedInputs);
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
- const conn = this.getConnection();
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.11.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.63.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
- const result = await runSql(connection, m.transaction, input, ctx);
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 { randomUUID } from "crypto";
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 transaction<T>(cb: () => Promise<T>): Promise<T> {
40
- const txId = randomUUID();
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
- return this.db.transaction().execute(async (trx: Transaction<any>) => {
43
- setTx(txId, { executor: trx });
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
- transaction?: SqlTransactionResource,
89
+ zone?: ZoneEntry,
90
+ ctx?: InvokeContext,
56
91
  ): Promise<QueryResult<T>> {
57
- const executor = this.resolveExecutor(transaction);
58
- return executor.executeQuery<T>(CompiledQuery.raw(sql, params));
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
- transaction?: SqlTransactionResource,
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, transaction);
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(transaction?: SqlTransactionResource): Kysely<any> {
96
- if (transaction) {
97
- transaction.assertActive();
98
- }
99
-
100
- const txId = currentTxId();
101
- if (txId) {
102
- const entry = getTx(txId);
103
- if (entry) {
104
- return entry.executor as Kysely<any>;
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
  }
@@ -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
- transaction?: SqlTransactionResource,
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
- transaction?: SqlTransactionResource,
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<T>(cb: () => Promise<T>): Promise<T>;
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
- const result = await runSql(connection, m.transaction, input, ctx);
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 { InvokeError, isParameterizedSql, type ResourceContext } from "@telorun/sdk";
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
- transaction: SqlTransactionResource | undefined,
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
- transaction,
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
- transaction,
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, m.transaction);
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 type { Invocable, ResourceContext, ResourceInstance } from "@telorun/sdk";
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: Invocable;
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
- assertActive(): void {
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
- // Flat nesting: if already inside a transaction, reuse it
42
- if (currentTxId()) {
43
- const expandedInputs = ctx.expandValue(m.inputs ?? {}, input ?? {});
44
- return m.steps.invoke(expandedInputs);
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
- const expandedInputs = ctx.expandValue(m.inputs ?? {}, input ?? {});
57
+ if (conn.hasOpenTransaction(invokeCtx)) {
58
+ return this.dispatchSteps(expandedInputs, invokeCtx);
59
+ }
49
60
 
50
- return conn.transaction(() => m.steps.invoke(expandedInputs));
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();
@@ -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();