@jskit-ai/database-runtime 0.1.11 → 0.1.13

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.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/database-runtime",
4
- version: "0.1.11",
4
+ version: "0.1.13",
5
5
  dependsOn: [
6
6
  "@jskit-ai/kernel"
7
7
  ],
@@ -55,7 +55,7 @@ export default Object.freeze({
55
55
  mutations: {
56
56
  dependencies: {
57
57
  runtime: {
58
- "@jskit-ai/kernel": "0.1.11",
58
+ "@jskit-ai/kernel": "0.1.12",
59
59
  "dotenv": "^16.4.5",
60
60
  "knex": "^3.1.0"
61
61
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/database-runtime",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -25,6 +25,6 @@
25
25
  "./shared/transactions": "./src/shared/transactions.js"
26
26
  },
27
27
  "dependencies": {
28
- "@jskit-ai/kernel": "0.1.11"
28
+ "@jskit-ai/kernel": "0.1.12"
29
29
  }
30
30
  }
@@ -7,6 +7,7 @@ import {
7
7
  normalizeDatabaseClient,
8
8
  toKnexClientId
9
9
  } from "../../shared/databaseClient.js";
10
+ import { resolveDatabaseConnectionFromEnvironment } from "../../shared/databaseConnection.js";
10
11
 
11
12
  const DATABASE_RUNTIME_TOKEN = "runtime.database";
12
13
  const DATABASE_DRIVER_TOKEN = "runtime.database.driver";
@@ -42,22 +43,6 @@ function resolveDriverDialectId(driver) {
42
43
  return "";
43
44
  }
44
45
 
45
- function resolveRequiredEnvString(env, key) {
46
- const value = normalizeText(env?.[key]);
47
- if (!value) {
48
- throw new Error(`${key} is required for database runtime.`);
49
- }
50
- return value;
51
- }
52
-
53
- function resolvePort(value, fallbackPort) {
54
- const parsed = Number.parseInt(normalizeText(value), 10);
55
- if (Number.isInteger(parsed) && parsed > 0) {
56
- return parsed;
57
- }
58
- return fallbackPort;
59
- }
60
-
61
46
  function loadKnexFactory() {
62
47
  let moduleValue;
63
48
  try {
@@ -142,16 +127,14 @@ function createKnexConfig(scope) {
142
127
 
143
128
  const client = toKnexClientId(dialectId);
144
129
  const defaultPort = dialectId === "pg" ? 5432 : 3306;
130
+ const connection = resolveDatabaseConnectionFromEnvironment(env, {
131
+ defaultPort,
132
+ context: "database runtime"
133
+ });
145
134
 
146
135
  return {
147
136
  client,
148
- connection: {
149
- host: normalizeText(env.DB_HOST) || "localhost",
150
- port: resolvePort(env.DB_PORT, defaultPort),
151
- database: resolveRequiredEnvString(env, "DB_NAME"),
152
- user: resolveRequiredEnvString(env, "DB_USER"),
153
- password: String(env.DB_PASSWORD ?? "")
154
- }
137
+ connection
155
138
  };
156
139
  }
157
140
 
@@ -0,0 +1,116 @@
1
+ import { normalizeDatabaseClient, normalizeText } from "./databaseClient.js";
2
+
3
+ const DATABASE_URL_PROTOCOL_TO_CLIENT = Object.freeze({
4
+ "mysql:": "mysql2",
5
+ "mysql2:": "mysql2",
6
+ "mariadb:": "mysql2",
7
+ "postgres:": "pg",
8
+ "postgresql:": "pg",
9
+ "pg:": "pg"
10
+ });
11
+
12
+ function toPositiveInteger(value, fallback) {
13
+ const parsed = Number.parseInt(normalizeText(value), 10);
14
+ if (Number.isInteger(parsed) && parsed > 0) {
15
+ return parsed;
16
+ }
17
+ return fallback;
18
+ }
19
+
20
+ function parseDatabaseUrl(databaseUrl, { context = "DATABASE_URL", allowEmpty = false } = {}) {
21
+ const normalized = normalizeText(databaseUrl);
22
+ if (!normalized) {
23
+ if (allowEmpty) {
24
+ return null;
25
+ }
26
+ throw new Error(`${context} is required.`);
27
+ }
28
+
29
+ let parsed;
30
+ try {
31
+ parsed = new URL(normalized);
32
+ } catch {
33
+ throw new Error(`${context} must be a valid absolute URL.`);
34
+ }
35
+
36
+ const databaseName = normalizeText(decodeURIComponent(String(parsed.pathname || "").replace(/^\/+/, "")));
37
+ const user = normalizeText(decodeURIComponent(parsed.username || ""));
38
+ const password = decodeURIComponent(parsed.password || "");
39
+
40
+ return Object.freeze({
41
+ protocol: normalizeText(parsed.protocol).toLowerCase(),
42
+ host: normalizeText(parsed.hostname),
43
+ port: toPositiveInteger(parsed.port, 0),
44
+ database: databaseName,
45
+ user,
46
+ password
47
+ });
48
+ }
49
+
50
+ function resolveDatabaseClientFromEnvironment(env = {}, { allowEmpty = false } = {}) {
51
+ const source = env && typeof env === "object" ? env : {};
52
+ const explicitClient = normalizeText(source.DB_CLIENT);
53
+ if (explicitClient) {
54
+ return normalizeDatabaseClient(explicitClient, { allowEmpty });
55
+ }
56
+
57
+ const parsedUrl = parseDatabaseUrl(source.DATABASE_URL, { allowEmpty: true });
58
+ const inferredClient = parsedUrl ? DATABASE_URL_PROTOCOL_TO_CLIENT[parsedUrl.protocol] || "" : "";
59
+ if (inferredClient) {
60
+ return normalizeDatabaseClient(inferredClient, { allowEmpty });
61
+ }
62
+
63
+ if (allowEmpty) {
64
+ return "";
65
+ }
66
+
67
+ if (parsedUrl) {
68
+ throw new Error(
69
+ `Unsupported DATABASE_URL protocol "${parsedUrl.protocol}". Use one of: mysql, mysql2, mariadb, postgres, postgresql, pg.`
70
+ );
71
+ }
72
+
73
+ throw new Error("DB_CLIENT is required. Set DB_CLIENT or DATABASE_URL.");
74
+ }
75
+
76
+ function resolveDatabaseConnectionFromEnvironment(
77
+ env = {},
78
+ {
79
+ defaultHost = "localhost",
80
+ defaultPort = 3306,
81
+ context = "database runtime"
82
+ } = {}
83
+ ) {
84
+ const source = env && typeof env === "object" ? env : {};
85
+ const parsedUrl = parseDatabaseUrl(source.DATABASE_URL, { allowEmpty: true });
86
+
87
+ const host = normalizeText(source.DB_HOST) || normalizeText(parsedUrl?.host) || defaultHost;
88
+ const port = toPositiveInteger(source.DB_PORT, parsedUrl?.port || defaultPort);
89
+
90
+ const database = normalizeText(source.DB_NAME) || normalizeText(parsedUrl?.database);
91
+ if (!database) {
92
+ throw new Error(`DB_NAME is required for ${context}. Set DB_NAME or DATABASE_URL.`);
93
+ }
94
+
95
+ const user = normalizeText(source.DB_USER) || normalizeText(parsedUrl?.user);
96
+ if (!user) {
97
+ throw new Error(`DB_USER is required for ${context}. Set DB_USER or DATABASE_URL.`);
98
+ }
99
+
100
+ const hasDbPassword = Object.prototype.hasOwnProperty.call(source, "DB_PASSWORD");
101
+ const password = hasDbPassword ? String(source.DB_PASSWORD ?? "") : String(parsedUrl?.password || "");
102
+
103
+ return Object.freeze({
104
+ host,
105
+ port,
106
+ database,
107
+ user,
108
+ password
109
+ });
110
+ }
111
+
112
+ export {
113
+ parseDatabaseUrl,
114
+ resolveDatabaseClientFromEnvironment,
115
+ resolveDatabaseConnectionFromEnvironment
116
+ };
@@ -15,6 +15,11 @@ export {
15
15
  } from "./dateUtils.js";
16
16
  export { normalizeDialect, detectDialectFromClient } from "./dialect.js";
17
17
  export { normalizeText, normalizeDatabaseClient, toKnexClientId } from "./databaseClient.js";
18
+ export {
19
+ parseDatabaseUrl,
20
+ resolveDatabaseClientFromEnvironment,
21
+ resolveDatabaseConnectionFromEnvironment
22
+ } from "./databaseConnection.js";
18
23
  export { isDuplicateEntryError } from "./duplicateEntry.js";
19
24
  export { normalizePath, jsonTextExpression, whereJsonTextEquals } from "./json.js";
20
25
  export { DEFAULT_VISIBILITY_COLUMNS, applyVisibility, applyVisibilityOwners } from "./visibility.js";
@@ -2,25 +2,10 @@ import path from "node:path";
2
2
  import dotenv from "dotenv";
3
3
  import {
4
4
  normalizeText,
5
- normalizeDatabaseClient,
6
- toKnexClientId
7
- } from "@jskit-ai/database-runtime/shared/databaseClient";
8
-
9
- function resolveRequiredEnvString(env, key) {
10
- const value = normalizeText(env[key]);
11
- if (!value) {
12
- throw new Error(`${key} is required.`);
13
- }
14
- return value;
15
- }
16
-
17
- function resolvePort(value, fallbackPort) {
18
- const parsed = Number.parseInt(normalizeText(value), 10);
19
- if (Number.isInteger(parsed) && parsed > 0) {
20
- return parsed;
21
- }
22
- return fallbackPort;
23
- }
5
+ toKnexClientId,
6
+ resolveDatabaseClientFromEnvironment,
7
+ resolveDatabaseConnectionFromEnvironment
8
+ } from "@jskit-ai/database-runtime/shared";
24
9
 
25
10
  const appRoot = process.cwd();
26
11
  dotenv.config({
@@ -28,20 +13,17 @@ dotenv.config({
28
13
  quiet: true
29
14
  });
30
15
 
31
- const dialectId = normalizeDatabaseClient(process.env.DB_CLIENT);
16
+ const dialectId = resolveDatabaseClientFromEnvironment(process.env);
32
17
  const client = toKnexClientId(dialectId);
33
18
  const defaultPort = dialectId === "pg" ? 5432 : 3306;
34
19
  const migrationsDirectory = path.resolve(appRoot, normalizeText(process.env.DB_MIGRATIONS_DIR) || "migrations");
35
20
 
36
21
  export default {
37
22
  client,
38
- connection: {
39
- host: normalizeText(process.env.DB_HOST) || "localhost",
40
- port: resolvePort(process.env.DB_PORT, defaultPort),
41
- database: resolveRequiredEnvString(process.env, "DB_NAME"),
42
- user: resolveRequiredEnvString(process.env, "DB_USER"),
43
- password: String(process.env.DB_PASSWORD ?? "")
44
- },
23
+ connection: resolveDatabaseConnectionFromEnvironment(process.env, {
24
+ defaultPort,
25
+ context: "knex migrations"
26
+ }),
45
27
  migrations: {
46
28
  directory: migrationsDirectory,
47
29
  extension: "cjs"
@@ -0,0 +1,60 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ parseDatabaseUrl,
5
+ resolveDatabaseClientFromEnvironment,
6
+ resolveDatabaseConnectionFromEnvironment
7
+ } from "../src/shared/databaseConnection.js";
8
+
9
+ test("parseDatabaseUrl parses mysql url fields", () => {
10
+ const parsed = parseDatabaseUrl("mysql://appuser:apppass@db.local:3307/appdb");
11
+ assert.equal(parsed.protocol, "mysql:");
12
+ assert.equal(parsed.host, "db.local");
13
+ assert.equal(parsed.port, 3307);
14
+ assert.equal(parsed.database, "appdb");
15
+ assert.equal(parsed.user, "appuser");
16
+ assert.equal(parsed.password, "apppass");
17
+ });
18
+
19
+ test("resolveDatabaseClientFromEnvironment infers client from DATABASE_URL when DB_CLIENT is absent", () => {
20
+ const client = resolveDatabaseClientFromEnvironment({
21
+ DATABASE_URL: "postgres://user:pass@db.local:5432/appdb"
22
+ });
23
+ assert.equal(client, "pg");
24
+ });
25
+
26
+ test("resolveDatabaseConnectionFromEnvironment falls back to DATABASE_URL values", () => {
27
+ const connection = resolveDatabaseConnectionFromEnvironment({
28
+ DATABASE_URL: "mysql://urluser:urlpass@db.url.local:3308/url_db_name"
29
+ }, {
30
+ defaultPort: 3306,
31
+ context: "database runtime"
32
+ });
33
+
34
+ assert.equal(connection.host, "db.url.local");
35
+ assert.equal(connection.port, 3308);
36
+ assert.equal(connection.database, "url_db_name");
37
+ assert.equal(connection.user, "urluser");
38
+ assert.equal(connection.password, "urlpass");
39
+ });
40
+
41
+ test("resolveDatabaseConnectionFromEnvironment keeps explicit DB_* values over DATABASE_URL", () => {
42
+ const connection = resolveDatabaseConnectionFromEnvironment({
43
+ DATABASE_URL: "mysql://urluser:urlpass@db.url.local:3308/url_db_name",
44
+ DB_HOST: "db.explicit.local",
45
+ DB_PORT: "3310",
46
+ DB_NAME: "explicit_db",
47
+ DB_USER: "explicit_user",
48
+ DB_PASSWORD: "explicit_pass"
49
+ }, {
50
+ defaultPort: 3306,
51
+ context: "database runtime"
52
+ });
53
+
54
+ assert.equal(connection.host, "db.explicit.local");
55
+ assert.equal(connection.port, 3310);
56
+ assert.equal(connection.database, "explicit_db");
57
+ assert.equal(connection.user, "explicit_user");
58
+ assert.equal(connection.password, "explicit_pass");
59
+ });
60
+
@@ -57,6 +57,47 @@ function createKnexStub() {
57
57
  };
58
58
  }
59
59
 
60
+ async function withAppRootKnexStub(callback) {
61
+ const appRoot = await mkdtemp(path.join(tmpdir(), "jskit-db-runtime-test-"));
62
+ const knexPackageDir = path.join(appRoot, "node_modules", "knex");
63
+ await mkdir(knexPackageDir, { recursive: true });
64
+ await writeFile(path.join(appRoot, "package.json"), JSON.stringify({ name: "runtime-app", private: true }), "utf8");
65
+ await writeFile(
66
+ path.join(knexPackageDir, "package.json"),
67
+ JSON.stringify({
68
+ name: "knex",
69
+ version: "0.0.0-test",
70
+ main: "index.js",
71
+ type: "commonjs"
72
+ }),
73
+ "utf8"
74
+ );
75
+ await writeFile(
76
+ path.join(knexPackageDir, "index.js"),
77
+ [
78
+ "module.exports = function knex(config) {",
79
+ " return {",
80
+ " __source: 'app-root-knex',",
81
+ " __config: config,",
82
+ " transaction: async function transaction(callback) {",
83
+ " return callback({ trxId: 'trx-app-root' });",
84
+ " }",
85
+ " };",
86
+ "};",
87
+ ""
88
+ ].join("\n"),
89
+ "utf8"
90
+ );
91
+
92
+ const previousCwd = process.cwd();
93
+ try {
94
+ process.chdir(appRoot);
95
+ await callback();
96
+ } finally {
97
+ process.chdir(previousCwd);
98
+ }
99
+ }
100
+
60
101
  test("DatabaseRuntimeServiceProvider registers runtime api", () => {
61
102
  const app = createSingletonApp();
62
103
  const provider = new DatabaseRuntimeServiceProvider();
@@ -112,41 +153,7 @@ test("DatabaseRuntimeServiceProvider driver token throws when multiple drivers a
112
153
  });
113
154
 
114
155
  test("DatabaseRuntimeServiceProvider resolves knex from app root package context", async () => {
115
- const appRoot = await mkdtemp(path.join(tmpdir(), "jskit-db-runtime-test-"));
116
- const knexPackageDir = path.join(appRoot, "node_modules", "knex");
117
- await mkdir(knexPackageDir, { recursive: true });
118
- await writeFile(path.join(appRoot, "package.json"), JSON.stringify({ name: "runtime-app", private: true }), "utf8");
119
- await writeFile(
120
- path.join(knexPackageDir, "package.json"),
121
- JSON.stringify({
122
- name: "knex",
123
- version: "0.0.0-test",
124
- main: "index.js",
125
- type: "commonjs"
126
- }),
127
- "utf8"
128
- );
129
- await writeFile(
130
- path.join(knexPackageDir, "index.js"),
131
- [
132
- "module.exports = function knex(config) {",
133
- " return {",
134
- " __source: 'app-root-knex',",
135
- " __config: config,",
136
- " transaction: async function transaction(callback) {",
137
- " return callback({ trxId: 'trx-app-root' });",
138
- " }",
139
- " };",
140
- "};",
141
- ""
142
- ].join("\n"),
143
- "utf8"
144
- );
145
-
146
- const previousCwd = process.cwd();
147
- try {
148
- process.chdir(appRoot);
149
-
156
+ await withAppRootKnexStub(async () => {
150
157
  const app = createSingletonApp();
151
158
  app.instance("runtime.database.driver.mysql", Object.freeze({ DIALECT_ID: "mysql2" }));
152
159
  app.instance(KERNEL_TOKENS.Env, {
@@ -168,7 +175,27 @@ test("DatabaseRuntimeServiceProvider resolves knex from app root package context
168
175
  assert.equal(knex.__config.connection.database, "appdb");
169
176
  assert.equal(knex.__config.connection.user, "appuser");
170
177
  assert.equal(knex.__config.connection.password, "apppass");
171
- } finally {
172
- process.chdir(previousCwd);
173
- }
178
+ });
179
+ });
180
+
181
+ test("DatabaseRuntimeServiceProvider resolves knex config from DATABASE_URL when DB_* vars are omitted", async () => {
182
+ await withAppRootKnexStub(async () => {
183
+ const app = createSingletonApp();
184
+ app.instance("runtime.database.driver.mysql", Object.freeze({ DIALECT_ID: "mysql2" }));
185
+ app.instance(KERNEL_TOKENS.Env, {
186
+ DATABASE_URL: "mysql://urluser:urlpass@db.url.local:3308/url_db_name"
187
+ });
188
+
189
+ const provider = new DatabaseRuntimeServiceProvider();
190
+ provider.register(app);
191
+
192
+ const knex = app.make(KERNEL_TOKENS.Knex);
193
+ assert.equal(knex.__source, "app-root-knex");
194
+ assert.equal(knex.__config.client, "mysql2");
195
+ assert.equal(knex.__config.connection.host, "db.url.local");
196
+ assert.equal(knex.__config.connection.port, 3308);
197
+ assert.equal(knex.__config.connection.database, "url_db_name");
198
+ assert.equal(knex.__config.connection.user, "urluser");
199
+ assert.equal(knex.__config.connection.password, "urlpass");
200
+ });
174
201
  });