@telorun/sql 0.12.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?
@@ -20,6 +20,18 @@ export declare abstract class SqlConnectionBase implements SqlConnection {
20
20
  teardown(): Promise<void>;
21
21
  runInTransaction<T>(body: (bind: (entry: ZoneEntry) => void) => Promise<T>): Promise<T>;
22
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
+ */
23
35
  execute<T>(sql: string, params?: unknown[], zone?: ZoneEntry, ctx?: InvokeContext): Promise<QueryResult<T>>;
24
36
  executeTemplate<T>(fragments: string[], values: unknown[], zone?: ZoneEntry, ctx?: InvokeContext): Promise<QueryResult<T>>;
25
37
  /** Hand the whole script to the driver as one statement. Backends whose driver
@@ -1,4 +1,4 @@
1
- import { InvokeError, } from "@telorun/sdk";
1
+ import { InvokeError, SEVERITY, } from "@telorun/sdk";
2
2
  import { CompiledQuery } from "kysely";
3
3
  /**
4
4
  * The dialect-neutral half of a connection: statement execution, transaction
@@ -35,16 +35,55 @@ export class SqlConnectionBase {
35
35
  await this.db.destroy();
36
36
  }
37
37
  async runInTransaction(body) {
38
- return this.db
39
- .transaction()
40
- .execute((trx) => body((entry) => this.#executors.set(entry, trx)));
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
+ }
41
53
  }
42
54
  hasOpenTransaction(ctx) {
43
55
  return this.ctx.zonesFor(this, ctx).some((entry) => this.#executors.has(entry));
44
56
  }
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
+ */
45
69
  async execute(sql, params = [], zone, ctx) {
46
70
  const executor = this.resolveExecutor(zone, ctx);
47
- return executor.executeQuery(CompiledQuery.raw(sql, params));
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;
48
87
  }
49
88
  async executeTemplate(fragments, values, zone, ctx) {
50
89
  let sql = fragments[0] ?? "";
@@ -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() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/sql",
3
- "version": "0.12.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.67.0"
46
+ "@telorun/sdk": "0.70.0"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@telorun/sdk": "*"
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  InvokeError,
3
+ SEVERITY,
3
4
  type InvokeContext,
4
5
  type ResourceContext,
5
6
  type ZoneEntry,
@@ -50,15 +51,38 @@ export abstract class SqlConnectionBase implements SqlConnection {
50
51
  async runInTransaction<T>(
51
52
  body: (bind: (entry: ZoneEntry) => void) => Promise<T>,
52
53
  ): Promise<T> {
53
- return this.db
54
- .transaction()
55
- .execute((trx: Transaction<any>) => body((entry) => this.#executors.set(entry, trx)));
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
+ }
56
68
  }
57
69
 
58
70
  hasOpenTransaction(ctx?: InvokeContext): boolean {
59
71
  return this.ctx.zonesFor(this, ctx).some((entry) => this.#executors.has(entry));
60
72
  }
61
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
+ */
62
86
  async execute<T>(
63
87
  sql: string,
64
88
  params: unknown[] = [],
@@ -66,7 +90,22 @@ export abstract class SqlConnectionBase implements SqlConnection {
66
90
  ctx?: InvokeContext,
67
91
  ): Promise<QueryResult<T>> {
68
92
  const executor = this.resolveExecutor(zone, ctx);
69
- return executor.executeQuery<T>(CompiledQuery.raw(sql, params));
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;
70
109
  }
71
110
 
72
111
  async executeTemplate<T>(
@@ -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