@jskit-ai/database-runtime 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ export default Object.freeze({
2
+ packageVersion: 1,
3
+ packageId: "@jskit-ai/database-runtime",
4
+ version: "0.1.4",
5
+ dependsOn: [
6
+ "@jskit-ai/kernel"
7
+ ],
8
+ capabilities: {
9
+ provides: [
10
+ "runtime.database"
11
+ ],
12
+ requires: [
13
+ "runtime.database.driver"
14
+ ]
15
+ },
16
+ runtime: {
17
+ server: {
18
+ providerEntrypoint: "src/server/providers/DatabaseRuntimeServiceProvider.js",
19
+ providers: [
20
+ {
21
+ entrypoint: "src/server/providers/DatabaseRuntimeServiceProvider.js",
22
+ export: "DatabaseRuntimeServiceProvider"
23
+ }
24
+ ]
25
+ },
26
+ client: {
27
+ providers: []
28
+ }
29
+ },
30
+ metadata: {
31
+ apiSummary: {
32
+ surfaces: [
33
+ {
34
+ subpath: "./server",
35
+ summary: "Exports DatabaseRuntimeServiceProvider plus registerDatabaseRuntime for server container wiring."
36
+ },
37
+ {
38
+ subpath: "./shared",
39
+ summary: "Exports shared Knex runtime utilities (transaction manager, repository helpers, retention/json/date/dialect helpers)."
40
+ },
41
+ {
42
+ subpath: "./client",
43
+ summary: "Exports no runtime API today (reserved client entrypoint)."
44
+ }
45
+ ],
46
+ containerTokens: {
47
+ server: [
48
+ "runtime.database",
49
+ "runtime.database.driver"
50
+ ],
51
+ client: []
52
+ }
53
+ }
54
+ },
55
+ mutations: {
56
+ dependencies: {
57
+ runtime: {
58
+ "@jskit-ai/kernel": "0.1.4",
59
+ "dotenv": "^16.4.5",
60
+ "knex": "^3.1.0"
61
+ },
62
+ dev: {}
63
+ },
64
+ packageJson: {
65
+ scripts: {
66
+ "db:migrate": "knex --knexfile ./knexfile.js migrate:latest",
67
+ "db:migrate:rollback": "knex --knexfile ./knexfile.js migrate:rollback",
68
+ "db:migrate:status": "knex --knexfile ./knexfile.js migrate:list"
69
+ }
70
+ },
71
+ procfile: {},
72
+ files: [
73
+ {
74
+ from: "templates/knexfile.js",
75
+ to: "knexfile.js",
76
+ reason: "Install root Knex configuration so app scripts can run migrations through Knex CLI.",
77
+ category: "database-runtime",
78
+ id: "database-runtime-knexfile"
79
+ },
80
+ {
81
+ from: "templates/migrations/.gitkeep",
82
+ to: "migrations/.gitkeep",
83
+ reason: "Ensure migrations directory exists so Knex migration commands can run before any module installs migrations.",
84
+ category: "database-runtime",
85
+ id: "database-runtime-migrations-dir"
86
+ }
87
+ ]
88
+ }
89
+ });
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@jskit-ai/database-runtime",
3
+ "version": "0.1.4",
4
+ "type": "module",
5
+ "scripts": {
6
+ "test": "node --test"
7
+ },
8
+ "exports": {
9
+ "./client": "./src/client/index.js",
10
+ "./server/providers/DatabaseRuntimeServiceProvider": "./src/server/providers/DatabaseRuntimeServiceProvider.js",
11
+ "./shared": "./src/shared/index.js",
12
+ "./shared/dateUtils": "./src/shared/dateUtils.js",
13
+ "./shared/databaseClient": "./src/shared/databaseClient.js",
14
+ "./shared/dialect": "./src/shared/dialect.js",
15
+ "./shared/duplicateEntry": "./src/shared/duplicateEntry.js",
16
+ "./shared/json": "./src/shared/json.js",
17
+ "./shared/repository": "./src/shared/repository.js",
18
+ "./shared/repositoryScope": "./src/shared/repositoryScope.js",
19
+ "./shared/repositoryOptions": "./src/shared/repositoryOptions.js",
20
+ "./shared/visibility": "./src/shared/visibility.js",
21
+ "./shared/retention": "./src/shared/retention.js",
22
+ "./shared/runtime": "./src/shared/runtime.js",
23
+ "./shared/runtimeErrors": "./src/shared/runtimeErrors.js",
24
+ "./shared/transactionManager": "./src/shared/transactionManager.js",
25
+ "./shared/transactions": "./src/shared/transactions.js"
26
+ },
27
+ "dependencies": {
28
+ "@jskit-ai/kernel": "0.1.4"
29
+ }
30
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,193 @@
1
+ import { KERNEL_TOKENS } from "@jskit-ai/kernel/shared/support/tokens";
2
+ import { symlinkSafeRequire } from "@jskit-ai/kernel/server/support";
3
+ import * as databaseRuntime from "../../shared/index.js";
4
+ import { createTransactionManager } from "../../shared/transactionManager.js";
5
+ import {
6
+ normalizeText,
7
+ normalizeDatabaseClient,
8
+ toKnexClientId
9
+ } from "../../shared/databaseClient.js";
10
+
11
+ const DATABASE_RUNTIME_TOKEN = "runtime.database";
12
+ const DATABASE_DRIVER_TOKEN = "runtime.database.driver";
13
+ const MYSQL_DRIVER_TOKEN = "runtime.database.driver.mysql";
14
+ const POSTGRES_DRIVER_TOKEN = "runtime.database.driver.postgres";
15
+
16
+ const DATABASE_RUNTIME_SERVER_API = Object.freeze({
17
+ ...databaseRuntime
18
+ });
19
+
20
+ function resolveDatabaseEnv(scope) {
21
+ const envFromScope = scope?.has?.(KERNEL_TOKENS.Env) ? scope.make(KERNEL_TOKENS.Env) : {};
22
+ const source = envFromScope && typeof envFromScope === "object" ? envFromScope : {};
23
+ return {
24
+ ...process.env,
25
+ ...source
26
+ };
27
+ }
28
+
29
+ function resolveDriverDialectId(driver) {
30
+ const source = driver && typeof driver === "object" ? driver : {};
31
+ const fromConstant = normalizeDatabaseClient(source.DIALECT_ID || source.dialectId || source.dialect, {
32
+ allowEmpty: true
33
+ });
34
+ if (fromConstant) {
35
+ return fromConstant;
36
+ }
37
+ if (typeof source.getDialectId === "function") {
38
+ return normalizeDatabaseClient(source.getDialectId(), {
39
+ allowEmpty: true
40
+ });
41
+ }
42
+ return "";
43
+ }
44
+
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
+ function loadKnexFactory() {
62
+ let moduleValue;
63
+ try {
64
+ moduleValue = symlinkSafeRequire("knex");
65
+ } catch {
66
+ throw new Error(
67
+ "Knex package is not installed. Re-run `npx jskit update package database-runtime` to apply runtime dependencies."
68
+ );
69
+ }
70
+
71
+ const knexFactory =
72
+ typeof moduleValue === "function"
73
+ ? moduleValue
74
+ : typeof moduleValue?.default === "function"
75
+ ? moduleValue.default
76
+ : null;
77
+ if (!knexFactory) {
78
+ throw new Error("Knex package resolved but did not expose a callable factory.");
79
+ }
80
+
81
+ return knexFactory;
82
+ }
83
+
84
+ function resolveRegisteredDriver(scope) {
85
+ const drivers = resolveRegisteredDrivers(scope);
86
+ if (drivers.length === 1) {
87
+ return drivers[0];
88
+ }
89
+ if (drivers.length < 1) {
90
+ throw new Error("No database driver is registered. Install @jskit-ai/database-runtime-mysql or @jskit-ai/database-runtime-postgres.");
91
+ }
92
+
93
+ const env = resolveDatabaseEnv(scope);
94
+ const preferredClient = normalizeDatabaseClient(env.DB_CLIENT, {
95
+ allowEmpty: true
96
+ });
97
+ if (!preferredClient) {
98
+ throw new Error("Multiple database drivers are registered. Set DB_CLIENT to mysql2 or pg, or keep exactly one database runtime driver package installed.");
99
+ }
100
+
101
+ const matched = drivers.find((driver) => resolveDriverDialectId(driver) === preferredClient) || null;
102
+ if (!matched) {
103
+ throw new Error(`Multiple database drivers are registered, but DB_CLIENT="${preferredClient}" did not match any registered driver.`);
104
+ }
105
+
106
+ return matched;
107
+ }
108
+
109
+ function resolveRegisteredDrivers(scope) {
110
+ const drivers = [];
111
+
112
+ if (scope.has(MYSQL_DRIVER_TOKEN)) {
113
+ drivers.push(scope.make(MYSQL_DRIVER_TOKEN));
114
+ }
115
+
116
+ if (scope.has(POSTGRES_DRIVER_TOKEN)) {
117
+ drivers.push(scope.make(POSTGRES_DRIVER_TOKEN));
118
+ }
119
+
120
+ return drivers;
121
+ }
122
+
123
+ function resolveSingleRegisteredDriver(scope) {
124
+ return resolveRegisteredDriver(scope);
125
+ }
126
+
127
+ function createKnexConfig(scope) {
128
+ const env = resolveDatabaseEnv(scope);
129
+ const configuredClient = normalizeDatabaseClient(env.DB_CLIENT, {
130
+ allowEmpty: true
131
+ });
132
+ const driver = resolveRegisteredDriver(scope);
133
+ const dialectId = resolveDriverDialectId(driver);
134
+
135
+ if (!dialectId) {
136
+ throw new Error("Selected database driver did not expose a valid dialect id.");
137
+ }
138
+
139
+ if (configuredClient && configuredClient !== dialectId) {
140
+ throw new Error(`DB_CLIENT="${configuredClient}" does not match installed database driver "${dialectId}".`);
141
+ }
142
+
143
+ const client = toKnexClientId(dialectId);
144
+ const defaultPort = dialectId === "pg" ? 5432 : 3306;
145
+
146
+ return {
147
+ 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
+ }
155
+ };
156
+ }
157
+
158
+ function createKnexInstance(scope) {
159
+ const knexFactory = loadKnexFactory();
160
+ const config = createKnexConfig(scope);
161
+ return knexFactory(config);
162
+ }
163
+
164
+ class DatabaseRuntimeServiceProvider {
165
+ static id = DATABASE_RUNTIME_TOKEN;
166
+
167
+ register(app) {
168
+ if (!app || typeof app.singleton !== "function" || typeof app.has !== "function") {
169
+ throw new Error("DatabaseRuntimeServiceProvider requires application singleton()/has().");
170
+ }
171
+
172
+ app.singleton(DATABASE_RUNTIME_TOKEN, () => DATABASE_RUNTIME_SERVER_API);
173
+
174
+ if (!app.has(DATABASE_DRIVER_TOKEN)) {
175
+ app.singleton(DATABASE_DRIVER_TOKEN, (scope) => resolveSingleRegisteredDriver(scope));
176
+ }
177
+
178
+ if (!app.has(KERNEL_TOKENS.Knex)) {
179
+ app.singleton(KERNEL_TOKENS.Knex, (scope) => createKnexInstance(scope));
180
+ }
181
+
182
+ if (!app.has(KERNEL_TOKENS.TransactionManager)) {
183
+ app.singleton(KERNEL_TOKENS.TransactionManager, (scope) => {
184
+ const knex = scope.make(KERNEL_TOKENS.Knex);
185
+ return createTransactionManager({ knex });
186
+ });
187
+ }
188
+ }
189
+
190
+ boot() {}
191
+ }
192
+
193
+ export { DatabaseRuntimeServiceProvider };
@@ -0,0 +1,31 @@
1
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+
3
+ function normalizeDatabaseClient(value, { allowEmpty = false } = {}) {
4
+ const normalized = normalizeText(value).toLowerCase();
5
+ if (!normalized) {
6
+ if (allowEmpty) {
7
+ return "";
8
+ }
9
+ throw new Error("DB_CLIENT is required. Use mysql2 or pg.");
10
+ }
11
+
12
+ if (normalized === "pg") {
13
+ return "pg";
14
+ }
15
+
16
+ if (normalized === "mysql2") {
17
+ return "mysql2";
18
+ }
19
+
20
+ throw new Error(`Unsupported DB_CLIENT "${normalized}". Use one of: mysql2, pg.`);
21
+ }
22
+
23
+ function toKnexClientId(databaseClient) {
24
+ return normalizeDatabaseClient(databaseClient);
25
+ }
26
+
27
+ export {
28
+ normalizeText,
29
+ normalizeDatabaseClient,
30
+ toKnexClientId
31
+ };
@@ -0,0 +1,57 @@
1
+ function normalizeDateInput(value) {
2
+ if (!value) {
3
+ return null;
4
+ }
5
+
6
+ const date = value instanceof Date ? value : new Date(value);
7
+ if (Number.isNaN(date.getTime())) {
8
+ return null;
9
+ }
10
+
11
+ return date;
12
+ }
13
+
14
+ function toDateOrThrow(value) {
15
+ const date = value instanceof Date ? value : new Date(value);
16
+ if (Number.isNaN(date.getTime())) {
17
+ throw new TypeError("Invalid date value.");
18
+ }
19
+
20
+ return date;
21
+ }
22
+
23
+ function pad(value, size = 2) {
24
+ return String(value).padStart(size, "0");
25
+ }
26
+
27
+ function toIsoString(value) {
28
+ return toDateOrThrow(value).toISOString();
29
+ }
30
+
31
+ function toInsertDateTime(dateLike, fallback = new Date()) {
32
+ const normalized = normalizeDateInput(dateLike) || normalizeDateInput(fallback) || new Date();
33
+ return toDatabaseDateTimeUtc(normalized);
34
+ }
35
+
36
+ function toNullableDateTime(value) {
37
+ const normalized = normalizeDateInput(value);
38
+ if (!normalized) {
39
+ return null;
40
+ }
41
+ return toDatabaseDateTimeUtc(normalized);
42
+ }
43
+
44
+ function toDatabaseDateTimeUtc(value) {
45
+ const date = toDateOrThrow(value);
46
+ const year = date.getUTCFullYear();
47
+ const month = pad(date.getUTCMonth() + 1);
48
+ const day = pad(date.getUTCDate());
49
+ const hours = pad(date.getUTCHours());
50
+ const minutes = pad(date.getUTCMinutes());
51
+ const seconds = pad(date.getUTCSeconds());
52
+ const milliseconds = pad(date.getUTCMilliseconds(), 3);
53
+
54
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
55
+ }
56
+
57
+ export { normalizeDateInput, toIsoString, toInsertDateTime, toNullableDateTime, toDatabaseDateTimeUtc };
@@ -0,0 +1,25 @@
1
+ function normalizeDialect(value) {
2
+ return String(value || "").trim().toLowerCase();
3
+ }
4
+
5
+ function detectDialectFromClient(client) {
6
+ const candidate =
7
+ client?.client?.config?.client ||
8
+ client?.client?.dialect ||
9
+ client?.client?.driverName ||
10
+ client?.client?.constructor?.name ||
11
+ "";
12
+
13
+ const normalized = normalizeDialect(candidate);
14
+ if (normalized.includes("postgres") || normalized === "pg") {
15
+ return "postgres";
16
+ }
17
+
18
+ if (normalized.includes("mysql") || normalized.includes("maria")) {
19
+ return "mysql";
20
+ }
21
+
22
+ return normalized;
23
+ }
24
+
25
+ export { normalizeDialect, detectDialectFromClient };
@@ -0,0 +1,37 @@
1
+ import { detectDialectFromClient, normalizeDialect } from "./dialect.js";
2
+
3
+ function isMysqlDuplicateEntryError(error) {
4
+ const code = String(error?.code || "").trim().toUpperCase();
5
+ if (code === "ER_DUP_ENTRY") {
6
+ return true;
7
+ }
8
+
9
+ const errno = Number(error?.errno || error?.errorno || 0);
10
+ return errno === 1062;
11
+ }
12
+
13
+ function isPostgresDuplicateEntryError(error) {
14
+ const code = String(error?.code || "").trim();
15
+ return code === "23505";
16
+ }
17
+
18
+ function isDuplicateEntryError(error, { dialect = "", client = null } = {}) {
19
+ if (!error) {
20
+ return false;
21
+ }
22
+
23
+ const resolvedDialect =
24
+ normalizeDialect(dialect) || (client ? normalizeDialect(detectDialectFromClient(client)) : "");
25
+
26
+ if (resolvedDialect === "postgres") {
27
+ return isPostgresDuplicateEntryError(error);
28
+ }
29
+
30
+ if (resolvedDialect === "mysql") {
31
+ return isMysqlDuplicateEntryError(error);
32
+ }
33
+
34
+ return isMysqlDuplicateEntryError(error) || isPostgresDuplicateEntryError(error);
35
+ }
36
+
37
+ export { isDuplicateEntryError };
@@ -0,0 +1,45 @@
1
+ export {
2
+ DatabaseRuntimeError,
3
+ TransactionManagerError,
4
+ RepositoryError
5
+ } from "./runtimeErrors.js";
6
+ export { TransactionManager, createTransactionManager } from "./transactionManager.js";
7
+ export { BaseRepository, buildPaginationMeta } from "./repository.js";
8
+ export { registerDatabaseRuntime } from "./runtime.js";
9
+ export {
10
+ normalizeDateInput,
11
+ toIsoString,
12
+ toInsertDateTime,
13
+ toNullableDateTime,
14
+ toDatabaseDateTimeUtc
15
+ } from "./dateUtils.js";
16
+ export { normalizeDialect, detectDialectFromClient } from "./dialect.js";
17
+ export { normalizeText, normalizeDatabaseClient, toKnexClientId } from "./databaseClient.js";
18
+ export { isDuplicateEntryError } from "./duplicateEntry.js";
19
+ export { normalizePath, jsonTextExpression, whereJsonTextEquals } from "./json.js";
20
+ export { DEFAULT_VISIBILITY_COLUMNS, applyVisibility, applyVisibilityOwners } from "./visibility.js";
21
+ export { createRepositoryScope } from "./repositoryScope.js";
22
+ export {
23
+ resolveQueryOptions,
24
+ resolveRepoClient,
25
+ applyForUpdate,
26
+ mapRowNullable,
27
+ parseJsonObject,
28
+ stringifyJsonObject,
29
+ parseMetadataJson,
30
+ stringifyMetadataJson,
31
+ normalizeMetadataJsonInput,
32
+ normalizeNullableString,
33
+ normalizeIdList,
34
+ normalizeCountRow,
35
+ parseJsonValue,
36
+ toDbJson
37
+ } from "./repositoryOptions.js";
38
+ export {
39
+ normalizeBatchSize,
40
+ normalizeCutoffDateOrThrow,
41
+ normalizeDeletedRowCount,
42
+ deleteRowsOlderThan,
43
+ __testables as retentionTestables
44
+ } from "./retention.js";
45
+ export { createRepoTransaction } from "./transactions.js";
@@ -0,0 +1,41 @@
1
+ import { detectDialectFromClient } from "./dialect.js";
2
+
3
+ function normalizePath(path) {
4
+ if (Array.isArray(path)) {
5
+ return path.map((part) => String(part || "").trim()).filter(Boolean);
6
+ }
7
+
8
+ const source = String(path || "").trim();
9
+ if (!source) {
10
+ return [];
11
+ }
12
+
13
+ return source
14
+ .split(".")
15
+ .map((part) => String(part || "").trim())
16
+ .filter(Boolean);
17
+ }
18
+
19
+ function jsonTextExpression({ client, column, path }) {
20
+ const normalizedPath = normalizePath(path);
21
+ if (!column || normalizedPath.length < 1) {
22
+ throw new TypeError("jsonTextExpression requires column and path.");
23
+ }
24
+
25
+ const dialect = detectDialectFromClient(client);
26
+ if (dialect === "postgres") {
27
+ const pgPath = normalizedPath.map((part) => part.replace(/[{}]/g, "")).join(",");
28
+ return client.raw("?? #>> ?", [column, `{${pgPath}}`]);
29
+ }
30
+
31
+ const mysqlPath = `$.${normalizedPath.join(".")}`;
32
+ return client.raw("JSON_UNQUOTE(JSON_EXTRACT(??, ?))", [column, mysqlPath]);
33
+ }
34
+
35
+ function whereJsonTextEquals(query, { column, path, value }) {
36
+ const expression = jsonTextExpression({ client: query.client, column, path });
37
+ query.whereRaw(`${expression.sql} = ?`, [...expression.bindings, String(value || "")]);
38
+ return query;
39
+ }
40
+
41
+ export { normalizePath, jsonTextExpression, whereJsonTextEquals };
@@ -0,0 +1,61 @@
1
+ import { normalizeInteger, normalizeObject } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { RepositoryError } from "./runtimeErrors.js";
3
+
4
+ function buildPaginationMeta({ total, page, pageSize } = {}) {
5
+ const normalizedTotal = Math.max(0, normalizeInteger(total, { fallback: 0 }));
6
+ const normalizedPageSize = Math.max(1, normalizeInteger(pageSize, { fallback: 25 }));
7
+ const normalizedPage = Math.max(1, normalizeInteger(page, { fallback: 1 }));
8
+ const pageCount = Math.max(1, Math.ceil(normalizedTotal / normalizedPageSize));
9
+
10
+ return Object.freeze({
11
+ total: normalizedTotal,
12
+ page: Math.min(normalizedPage, pageCount),
13
+ pageSize: normalizedPageSize,
14
+ pageCount,
15
+ hasPrev: normalizedPage > 1,
16
+ hasNext: normalizedPage < pageCount
17
+ });
18
+ }
19
+
20
+ class BaseRepository {
21
+ constructor({ knex, transactionManager } = {}) {
22
+ if (!knex) {
23
+ throw new RepositoryError("BaseRepository requires knex.");
24
+ }
25
+ if (!transactionManager || typeof transactionManager.inTransaction !== "function") {
26
+ throw new RepositoryError("BaseRepository requires transactionManager.inTransaction().");
27
+ }
28
+
29
+ this.knex = knex;
30
+ this.transactionManager = transactionManager;
31
+ }
32
+
33
+ async withTransaction(work, options = {}) {
34
+ if (typeof work !== "function") {
35
+ throw new RepositoryError("withTransaction requires a callback.");
36
+ }
37
+
38
+ return this.transactionManager.inTransaction(work, normalizeObject(options));
39
+ }
40
+
41
+ paginateRows(rows = [], options = {}) {
42
+ const sourceRows = Array.isArray(rows) ? rows : [];
43
+ const pageSize = Math.max(1, normalizeInteger(options.pageSize, { fallback: 25 }));
44
+ const page = Math.max(1, normalizeInteger(options.page, { fallback: 1 }));
45
+ const meta = buildPaginationMeta({
46
+ total: sourceRows.length,
47
+ page,
48
+ pageSize
49
+ });
50
+
51
+ const start = (meta.page - 1) * meta.pageSize;
52
+ const end = start + meta.pageSize;
53
+
54
+ return Object.freeze({
55
+ rows: Object.freeze(sourceRows.slice(start, end)),
56
+ meta
57
+ });
58
+ }
59
+ }
60
+
61
+ export { BaseRepository, buildPaginationMeta };