@stacksjs/database 0.70.255 → 0.70.257
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/dist/auth-tables.js +12 -12
- package/dist/database.js +2 -1
- package/dist/datetime-columns.d.ts +36 -0
- package/dist/datetime-columns.js +85 -0
- package/dist/ddl-constraints.d.ts +31 -0
- package/dist/ddl-constraints.js +111 -0
- package/dist/dialect.d.ts +82 -0
- package/dist/dialect.js +79 -0
- package/dist/driver-config.d.ts +55 -5
- package/dist/driver-config.js +28 -0
- package/dist/drivers/defaults/index.d.ts +0 -1
- package/dist/drivers/defaults/index.js +0 -1
- package/dist/drivers/defaults/traits.d.ts +17 -31
- package/dist/drivers/defaults/traits.js +18 -1142
- package/dist/drivers/mysql.d.ts +0 -1
- package/dist/drivers/mysql.js +1 -14
- package/dist/drivers/postgres.d.ts +0 -1
- package/dist/drivers/postgres.js +2 -35
- package/dist/index.d.ts +20 -0
- package/dist/index.js +6 -0
- package/dist/notification-tables.js +6 -6
- package/dist/query-logger.js +2 -1
- package/dist/rbac-tables.d.ts +3 -3
- package/dist/rbac-tables.js +16 -13
- package/dist/replicas.d.ts +52 -0
- package/dist/replicas.js +74 -0
- package/dist/schema.d.ts +1 -0
- package/dist/sql-helpers.d.ts +60 -7
- package/dist/sql-helpers.js +31 -5
- package/dist/trait-tables.d.ts +126 -0
- package/dist/trait-tables.js +206 -0
- package/dist/utils.d.ts +33 -0
- package/dist/utils.js +102 -13
- package/dist/vschema.d.ts +84 -0
- package/dist/vschema.js +121 -0
- package/package.json +11 -11
- package/dist/drivers/defaults/passwords.d.ts +0 -4
- package/dist/drivers/defaults/passwords.js +0 -106
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { sqlHelpers } from './sql-helpers';
|
|
2
|
+
/**
|
|
3
|
+
* The tables {@link migrateTraitTables} owns.
|
|
4
|
+
*
|
|
5
|
+
* The single source of truth for this set: the reset paths in
|
|
6
|
+
* `drivers/defaults/traits.ts` drop exactly these, so a table added here is
|
|
7
|
+
* dropped by `migrate:fresh` without a second list to remember.
|
|
8
|
+
*/
|
|
9
|
+
export declare function traitTableNames(): string[];
|
|
10
|
+
/**
|
|
11
|
+
* `commentables` — comments attached polymorphically to an owning model.
|
|
12
|
+
* `commentables_id` + `commentables_type` identify the owner (e.g. 123 /
|
|
13
|
+
* 'posts'). Columns match `CommentablesTable` and the writes in
|
|
14
|
+
* `cms/src/commentables/store.ts` and `orm/src/traits/commentable.ts`.
|
|
15
|
+
*
|
|
16
|
+
* `approved_at` / `rejected_at` hold epoch milliseconds, which overflows a
|
|
17
|
+
* 32-bit INTEGER on Postgres and MySQL — hence BIGINT. SQLite gives every
|
|
18
|
+
* INTEGER 64 bits, so the same declaration is correct there too.
|
|
19
|
+
*
|
|
20
|
+
* This is a distinct table from `comments`: `comments` is a model-backed
|
|
21
|
+
* table, `commentables` is the polymorphic trait table.
|
|
22
|
+
*/
|
|
23
|
+
export declare function commentablesTableSql(sql: SqlHelpers): string;
|
|
24
|
+
/**
|
|
25
|
+
* `taggables` — the tag catalogue, scoped to an owning model type.
|
|
26
|
+
*
|
|
27
|
+
* The two consumers disagree about ownership: the CMS module treats a row as a
|
|
28
|
+
* type-scoped tag with no single owner, while `orm/src/traits/taggable.ts`
|
|
29
|
+
* writes one row per owner and filters on `taggable_id`. Both are served by
|
|
30
|
+
* defaulting `taggable_id` to {@link UNSCOPED_OWNER_ID} — a CMS write lands on
|
|
31
|
+
* the sentinel, an ORM write carries the real owner, and the unique index is
|
|
32
|
+
* scoped by both so neither collides with the other.
|
|
33
|
+
*/
|
|
34
|
+
export declare function taggablesTableSql(sql: SqlHelpers): string;
|
|
35
|
+
/**
|
|
36
|
+
* `categorizables` — the category catalogue, scoped to an owning model type.
|
|
37
|
+
* The owner link lives in the model-declared `categorizable_models` pivot,
|
|
38
|
+
* which `orm/src/traits/categorizable.ts` reads to resolve category ids.
|
|
39
|
+
*/
|
|
40
|
+
export declare function categorizablesTableSql(sql: SqlHelpers): string;
|
|
41
|
+
/**
|
|
42
|
+
* `taggable_models` — links an owning record to a row in the tag catalogue.
|
|
43
|
+
*
|
|
44
|
+
* Guaranteed here because the trait needs it whenever a model sets
|
|
45
|
+
* `taggable: true`. It otherwise only exists when a model *separately*
|
|
46
|
+
* declares a matching `belongsToMany` pivot (as the framework's Post model
|
|
47
|
+
* does), which left `taggable: true` on its own pointing at a missing table.
|
|
48
|
+
*/
|
|
49
|
+
export declare function taggableModelsTableSql(sql: SqlHelpers): string;
|
|
50
|
+
/**
|
|
51
|
+
* `categorizable_models` — the categorizable counterpart to
|
|
52
|
+
* {@link taggableModelsTableSql}. `orm/src/traits/categorizable.ts` reads this
|
|
53
|
+
* table directly to resolve the category ids an owner is filed under.
|
|
54
|
+
*/
|
|
55
|
+
export declare function categorizableModelsTableSql(sql: SqlHelpers): string;
|
|
56
|
+
/**
|
|
57
|
+
* `<table>_likes` — the per-model table the `likeable` trait reads and writes.
|
|
58
|
+
*
|
|
59
|
+
* Unlike every other trait table this one has no fixed name: the trait derives
|
|
60
|
+
* it from the owning model (`posts` → `posts_likes`), or from
|
|
61
|
+
* `likeable: { table, foreignKey }`. Nothing created it on any driver, so
|
|
62
|
+
* `likeable: true` failed with "no such table" exactly the way the
|
|
63
|
+
* polymorphic traits did.
|
|
64
|
+
*
|
|
65
|
+
* `UNIQUE (foreignKey, user_id)` is the constraint `like()` already depends on:
|
|
66
|
+
* it catches the duplicate and returns the existing row instead of inserting
|
|
67
|
+
* twice.
|
|
68
|
+
*/
|
|
69
|
+
export declare function likesTableSql(sql: SqlHelpers, table: string, foreignKey: string): string;
|
|
70
|
+
/**
|
|
71
|
+
* `commentable_upvotes` — upvotes on a polymorphic target. Columns match
|
|
72
|
+
* `CommentableUpvotesTable`.
|
|
73
|
+
*/
|
|
74
|
+
export declare function commentableUpvotesTableSql(sql: SqlHelpers): string;
|
|
75
|
+
/**
|
|
76
|
+
* Secondary indexes for the trait tables.
|
|
77
|
+
*
|
|
78
|
+
* The unique indexes are the catalogue contract the CMS `findOrCreate` paths
|
|
79
|
+
* rely on: a tag/category name is unique *within* an owning model type, so
|
|
80
|
+
* 'posts' and 'products' can each have a "news" tag.
|
|
81
|
+
*
|
|
82
|
+
* Kept separate from the CREATE TABLE statements because not every dialect
|
|
83
|
+
* accepts `CREATE INDEX IF NOT EXISTS` — see
|
|
84
|
+
* `DialectCapabilities.supportsCreateIndexIfNotExists`. Where it isn't
|
|
85
|
+
* supported, {@link migrateTraitTables} drops the clause and tolerates the
|
|
86
|
+
* duplicate-index error on replay instead.
|
|
87
|
+
*/
|
|
88
|
+
export declare function traitTableIndexSql(): string[];
|
|
89
|
+
/**
|
|
90
|
+
* The `<table>_likes` tables to create, one per model that sets `likeable`.
|
|
91
|
+
*
|
|
92
|
+
* Model discovery mirrors the reset paths (`dropSqliteTables`, …): userland
|
|
93
|
+
* models first, then the framework defaults. The orm helpers are imported
|
|
94
|
+
* lazily because this module is a leaf that the drivers barrel imports —
|
|
95
|
+
* pulling `@stacksjs/orm` in at the top level would re-enter that barrel and
|
|
96
|
+
* deadlock bun's module loader (see `drivers/helpers.ts`).
|
|
97
|
+
*/
|
|
98
|
+
export declare function likeableTargets(): Promise<Array<{ table: string, foreignKey: string }>>;
|
|
99
|
+
/**
|
|
100
|
+
* Strip `IF NOT EXISTS` for dialects that reject it on `CREATE INDEX`, and let
|
|
101
|
+
* {@link isDuplicateIndexError} absorb the replay there instead.
|
|
102
|
+
*/
|
|
103
|
+
export declare function indexSqlForDialect(statement: string, dialect: string): string;
|
|
104
|
+
/**
|
|
105
|
+
* Create the polymorphic trait tables. Idempotent (`IF NOT EXISTS`), so it's
|
|
106
|
+
* safe to run on every `buddy migrate`.
|
|
107
|
+
*
|
|
108
|
+
* Runs AFTER the model migrations for the same reason the notification and
|
|
109
|
+
* RBAC guarantees do: an app model that legitimately owns one of these table
|
|
110
|
+
* names must stay authoritative, and pre-creating the table would suppress
|
|
111
|
+
* its generated migration.
|
|
112
|
+
*/
|
|
113
|
+
export declare function migrateTraitTables(options?: { verbose?: boolean }): Promise<{ success: boolean, error?: string }>;
|
|
114
|
+
/**
|
|
115
|
+
* The `taggable_id` / `categorizable_id` value that marks a catalogue row —
|
|
116
|
+
* a tag or category that belongs to an owning model *type* but to no
|
|
117
|
+
* individual record.
|
|
118
|
+
*
|
|
119
|
+
* A sentinel rather than NULL because the uniqueness rule has to hold on all
|
|
120
|
+
* three engines: SQLite and MySQL treat NULLs as distinct in a UNIQUE index
|
|
121
|
+
* (so two catalogue rows with the same slug would both be accepted), and
|
|
122
|
+
* `NULLS NOT DISTINCT` is Postgres 15+ only. A concrete value makes
|
|
123
|
+
* `UNIQUE (type, owner_id, slug)` mean the same thing everywhere.
|
|
124
|
+
*/
|
|
125
|
+
export declare const UNSCOPED_OWNER_ID: 0;
|
|
126
|
+
declare type SqlHelpers = ReturnType<typeof sqlHelpers>;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { log } from "@stacksjs/logging";
|
|
2
|
+
import { env as envVars } from "@stacksjs/env";
|
|
3
|
+
import { db } from "./utils";
|
|
4
|
+
import { sqlHelpers } from "./sql-helpers";
|
|
5
|
+
import { dialectCapabilities } from "./dialect";
|
|
6
|
+
function getDbDriver() {
|
|
7
|
+
return process.env.DB_CONNECTION || envVars.DB_CONNECTION || "sqlite";
|
|
8
|
+
}
|
|
9
|
+
function createdAt(sql) {
|
|
10
|
+
return `created_at ${sql.datetime}`;
|
|
11
|
+
}
|
|
12
|
+
function updatedAt(sql) {
|
|
13
|
+
return `updated_at ${sql.nullableTimestamp}`;
|
|
14
|
+
}
|
|
15
|
+
export function traitTableNames() {
|
|
16
|
+
return [
|
|
17
|
+
"commentables",
|
|
18
|
+
"taggables",
|
|
19
|
+
"categorizables",
|
|
20
|
+
"commentable_upvotes",
|
|
21
|
+
"taggable_models",
|
|
22
|
+
"categorizable_models"
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
export const UNSCOPED_OWNER_ID = 0;
|
|
26
|
+
export function commentablesTableSql(sql) {
|
|
27
|
+
const { pkColumn, boolTrue } = sql;
|
|
28
|
+
return `CREATE TABLE IF NOT EXISTS commentables (
|
|
29
|
+
${pkColumn},
|
|
30
|
+
title VARCHAR(255) NOT NULL,
|
|
31
|
+
body TEXT NOT NULL,
|
|
32
|
+
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
|
33
|
+
approved_at BIGINT,
|
|
34
|
+
rejected_at BIGINT,
|
|
35
|
+
commentables_id INTEGER NOT NULL,
|
|
36
|
+
commentables_type VARCHAR(255) NOT NULL,
|
|
37
|
+
user_id INTEGER,
|
|
38
|
+
is_active BOOLEAN NOT NULL DEFAULT ${boolTrue},
|
|
39
|
+
${createdAt(sql)},
|
|
40
|
+
${updatedAt(sql)}
|
|
41
|
+
)`;
|
|
42
|
+
}
|
|
43
|
+
export function taggablesTableSql(sql) {
|
|
44
|
+
const { pkColumn, boolTrue } = sql;
|
|
45
|
+
return `CREATE TABLE IF NOT EXISTS taggables (
|
|
46
|
+
${pkColumn},
|
|
47
|
+
name VARCHAR(255) NOT NULL,
|
|
48
|
+
slug VARCHAR(255) NOT NULL,
|
|
49
|
+
description TEXT,
|
|
50
|
+
is_active BOOLEAN NOT NULL DEFAULT ${boolTrue},
|
|
51
|
+
taggable_id INTEGER NOT NULL DEFAULT ${UNSCOPED_OWNER_ID},
|
|
52
|
+
taggable_type VARCHAR(255) NOT NULL,
|
|
53
|
+
${createdAt(sql)},
|
|
54
|
+
${updatedAt(sql)}
|
|
55
|
+
)`;
|
|
56
|
+
}
|
|
57
|
+
export function categorizablesTableSql(sql) {
|
|
58
|
+
const { pkColumn, boolTrue } = sql;
|
|
59
|
+
return `CREATE TABLE IF NOT EXISTS categorizables (
|
|
60
|
+
${pkColumn},
|
|
61
|
+
name VARCHAR(255) NOT NULL,
|
|
62
|
+
slug VARCHAR(255) NOT NULL,
|
|
63
|
+
description TEXT,
|
|
64
|
+
is_active BOOLEAN NOT NULL DEFAULT ${boolTrue},
|
|
65
|
+
categorizable_id INTEGER NOT NULL DEFAULT ${UNSCOPED_OWNER_ID},
|
|
66
|
+
categorizable_type VARCHAR(255) NOT NULL,
|
|
67
|
+
${createdAt(sql)},
|
|
68
|
+
${updatedAt(sql)}
|
|
69
|
+
)`;
|
|
70
|
+
}
|
|
71
|
+
export function taggableModelsTableSql(sql) {
|
|
72
|
+
const { pkColumn } = sql;
|
|
73
|
+
return `CREATE TABLE IF NOT EXISTS taggable_models (
|
|
74
|
+
${pkColumn},
|
|
75
|
+
tag_id INTEGER NOT NULL,
|
|
76
|
+
taggable_id INTEGER NOT NULL,
|
|
77
|
+
taggable_type VARCHAR(255) NOT NULL,
|
|
78
|
+
${createdAt(sql)},
|
|
79
|
+
${updatedAt(sql)}
|
|
80
|
+
)`;
|
|
81
|
+
}
|
|
82
|
+
export function categorizableModelsTableSql(sql) {
|
|
83
|
+
const { pkColumn } = sql;
|
|
84
|
+
return `CREATE TABLE IF NOT EXISTS categorizable_models (
|
|
85
|
+
${pkColumn},
|
|
86
|
+
category_id INTEGER NOT NULL,
|
|
87
|
+
categorizable_id INTEGER NOT NULL,
|
|
88
|
+
categorizable_type VARCHAR(255) NOT NULL,
|
|
89
|
+
${createdAt(sql)},
|
|
90
|
+
${updatedAt(sql)}
|
|
91
|
+
)`;
|
|
92
|
+
}
|
|
93
|
+
export function likesTableSql(sql, table, foreignKey) {
|
|
94
|
+
const { pkColumn } = sql;
|
|
95
|
+
return `CREATE TABLE IF NOT EXISTS ${table} (
|
|
96
|
+
${pkColumn},
|
|
97
|
+
${foreignKey} INTEGER NOT NULL,
|
|
98
|
+
user_id INTEGER NOT NULL,
|
|
99
|
+
${createdAt(sql)},
|
|
100
|
+
${updatedAt(sql)},
|
|
101
|
+
UNIQUE (${foreignKey}, user_id)
|
|
102
|
+
)`;
|
|
103
|
+
}
|
|
104
|
+
export function commentableUpvotesTableSql(sql) {
|
|
105
|
+
const { pkColumn } = sql;
|
|
106
|
+
return `CREATE TABLE IF NOT EXISTS commentable_upvotes (
|
|
107
|
+
${pkColumn},
|
|
108
|
+
user_id INTEGER,
|
|
109
|
+
upvoteable_id INTEGER NOT NULL,
|
|
110
|
+
upvoteable_type VARCHAR(255) NOT NULL,
|
|
111
|
+
${createdAt(sql)}
|
|
112
|
+
)`;
|
|
113
|
+
}
|
|
114
|
+
export function traitTableIndexSql() {
|
|
115
|
+
return [
|
|
116
|
+
"CREATE INDEX IF NOT EXISTS commentables_owner_index ON commentables (commentables_type, commentables_id)",
|
|
117
|
+
"CREATE INDEX IF NOT EXISTS commentables_status_index ON commentables (status)",
|
|
118
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS taggables_owner_slug_unique ON taggables (taggable_type, taggable_id, slug)",
|
|
119
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS categorizables_owner_slug_unique ON categorizables (categorizable_type, categorizable_id, slug)",
|
|
120
|
+
"CREATE INDEX IF NOT EXISTS commentable_upvotes_target_index ON commentable_upvotes (upvoteable_type, upvoteable_id)",
|
|
121
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS commentable_upvotes_user_unique ON commentable_upvotes (upvoteable_type, upvoteable_id, user_id)",
|
|
122
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS taggable_models_unique ON taggable_models (tag_id, taggable_id, taggable_type)",
|
|
123
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS categorizable_models_unique ON categorizable_models (category_id, categorizable_id, categorizable_type)"
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
export async function likeableTargets() {
|
|
127
|
+
const { path } = await import("@stacksjs/path"), { globSync } = await import("@stacksjs/storage"), { getTableName } = await import("@stacksjs/orm"), { getLikeableForeignKey, getUpvoteTableName } = await import("./drivers/helpers"), modelFiles = globSync([path.userModelsPath("*.ts"), path.storagePath("framework/defaults/app/Models/**/*.ts")], { absolute: !0 }), targets = new Map;
|
|
128
|
+
for (const modelFile of modelFiles) {
|
|
129
|
+
let model;
|
|
130
|
+
try {
|
|
131
|
+
model = (await import(modelFile)).default;
|
|
132
|
+
} catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (!model?.traits?.likeable)
|
|
136
|
+
continue;
|
|
137
|
+
const tableName = await getTableName(model, modelFile);
|
|
138
|
+
if (!tableName)
|
|
139
|
+
continue;
|
|
140
|
+
const table = getUpvoteTableName(model, tableName);
|
|
141
|
+
if (!table || !/^[a-z_]\w*$/i.test(table))
|
|
142
|
+
continue;
|
|
143
|
+
const foreignKey = getLikeableForeignKey(model, tableName);
|
|
144
|
+
if (!/^[a-z_]\w*$/i.test(foreignKey))
|
|
145
|
+
continue;
|
|
146
|
+
targets.set(table, { table, foreignKey });
|
|
147
|
+
}
|
|
148
|
+
return [...targets.values()];
|
|
149
|
+
}
|
|
150
|
+
export function indexSqlForDialect(statement, dialect) {
|
|
151
|
+
if (dialectCapabilities(dialect).supportsCreateIndexIfNotExists)
|
|
152
|
+
return statement;
|
|
153
|
+
return statement.replace(/^(CREATE (?:UNIQUE )?INDEX) IF NOT EXISTS /i, "$1 ");
|
|
154
|
+
}
|
|
155
|
+
function isDuplicateIndexError(error) {
|
|
156
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
157
|
+
return /duplicate key name|already exists/i.test(message);
|
|
158
|
+
}
|
|
159
|
+
export async function migrateTraitTables(options = {}) {
|
|
160
|
+
const dbDriver = getDbDriver(), sql = sqlHelpers(dbDriver);
|
|
161
|
+
if (options.verbose)
|
|
162
|
+
log.info(`Creating polymorphic trait tables for ${dbDriver}...`);
|
|
163
|
+
try {
|
|
164
|
+
if (options.verbose)
|
|
165
|
+
log.info("Creating commentables table...");
|
|
166
|
+
await db.unsafe(commentablesTableSql(sql)).execute();
|
|
167
|
+
if (options.verbose)
|
|
168
|
+
log.info("Creating taggables table...");
|
|
169
|
+
await db.unsafe(taggablesTableSql(sql)).execute();
|
|
170
|
+
if (options.verbose)
|
|
171
|
+
log.info("Creating categorizables table...");
|
|
172
|
+
await db.unsafe(categorizablesTableSql(sql)).execute();
|
|
173
|
+
if (options.verbose)
|
|
174
|
+
log.info("Creating commentable_upvotes table...");
|
|
175
|
+
await db.unsafe(commentableUpvotesTableSql(sql)).execute();
|
|
176
|
+
if (options.verbose)
|
|
177
|
+
log.info("Creating taggable_models pivot...");
|
|
178
|
+
await db.unsafe(taggableModelsTableSql(sql)).execute();
|
|
179
|
+
if (options.verbose)
|
|
180
|
+
log.info("Creating categorizable_models pivot...");
|
|
181
|
+
await db.unsafe(categorizableModelsTableSql(sql)).execute();
|
|
182
|
+
try {
|
|
183
|
+
for (const { table, foreignKey } of await likeableTargets()) {
|
|
184
|
+
if (options.verbose)
|
|
185
|
+
log.info(`Creating ${table} table...`);
|
|
186
|
+
await db.unsafe(likesTableSql(sql, table, foreignKey)).execute();
|
|
187
|
+
}
|
|
188
|
+
} catch (error) {
|
|
189
|
+
log.debug(`[trait-tables] Skipped likeable tables: ${error instanceof Error ? error.message : String(error)}`);
|
|
190
|
+
}
|
|
191
|
+
for (const statement of traitTableIndexSql())
|
|
192
|
+
try {
|
|
193
|
+
await db.unsafe(indexSqlForDialect(statement, dbDriver)).execute();
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (!isDuplicateIndexError(error))
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
if (options.verbose)
|
|
199
|
+
log.success("Polymorphic trait tables created");
|
|
200
|
+
return { success: !0 };
|
|
201
|
+
} catch (error) {
|
|
202
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
203
|
+
log.error(`Failed to create polymorphic trait tables: ${message}`);
|
|
204
|
+
return { success: !1, error: message };
|
|
205
|
+
}
|
|
206
|
+
}
|
package/dist/utils.d.ts
CHANGED
|
@@ -4,8 +4,28 @@ export declare function acquireDbConfigLock(): Promise<() => void>;
|
|
|
4
4
|
// Function to initialize the config when it's available
|
|
5
5
|
export declare function initializeDbConfig(config: any): void;
|
|
6
6
|
export declare function createDatabaseQueryHooks(dispatch: (event: DatabaseQueryLogEvent) => void | Promise<void>): QueryHooks;
|
|
7
|
+
declare function ensureConfigLoaded(): Promise<void>;
|
|
7
8
|
export declare function ensureDatabaseConfigLoaded(): Promise<void>;
|
|
8
9
|
declare function getDb(): ReturnType<typeof createQueryBuilder>;
|
|
10
|
+
/**
|
|
11
|
+
* The builder a read should use right now.
|
|
12
|
+
*
|
|
13
|
+
* Falls back to the primary whenever routing is not permitted — no
|
|
14
|
+
* replicas configured, auto-routing off, inside a transaction, or after a
|
|
15
|
+
* write in this async context. See `./replicas` for why each of those
|
|
16
|
+
* carve-outs exists.
|
|
17
|
+
*/
|
|
18
|
+
declare function getReadDb(): ReturnType<typeof createQueryBuilder>;
|
|
19
|
+
/**
|
|
20
|
+
* Explicitly replica-routed handle: `db.read.selectFrom('users')`.
|
|
21
|
+
*
|
|
22
|
+
* Unlike automatic routing this ignores `reads.autoRoute` — asking for
|
|
23
|
+
* `db.read` IS the statement that this particular query tolerates a stale
|
|
24
|
+
* result. It still respects the transaction carve-out, because a read
|
|
25
|
+
* inside a transaction must see that transaction's own writes no matter
|
|
26
|
+
* how it was requested.
|
|
27
|
+
*/
|
|
28
|
+
declare function getExplicitReadDb(): ReturnType<typeof createQueryBuilder>;
|
|
9
29
|
/**
|
|
10
30
|
* Update bun-query-builder configuration
|
|
11
31
|
*/
|
|
@@ -33,11 +53,23 @@ export declare const RAW_QUERY_SOFT_DELETE_CONFIG: {
|
|
|
33
53
|
column: 'deleted_at';
|
|
34
54
|
defaultFilter: true
|
|
35
55
|
};
|
|
56
|
+
/** Statements that mutate, for the read-your-writes tracking in `./replicas`. */
|
|
57
|
+
declare const WRITE_ENTRY_POINTS: Set<any>;
|
|
58
|
+
/** Reads that are candidates for replica routing. */
|
|
59
|
+
declare const READ_ENTRY_POINTS: Set<any>;
|
|
36
60
|
/**
|
|
37
61
|
* Lazy proxy for the query builder - connection is only made when first used.
|
|
38
62
|
* This is the main entry point for database operations.
|
|
39
63
|
*/
|
|
40
64
|
export declare const db: Proxy;
|
|
65
|
+
/**
|
|
66
|
+
* Replica-routed handle exposed as `db.read`.
|
|
67
|
+
*
|
|
68
|
+
* A separate proxy rather than a method so the whole builder surface stays
|
|
69
|
+
* available behind it (`db.read.selectFrom(...).where(...)`) without
|
|
70
|
+
* re-declaring every chain entry point.
|
|
71
|
+
*/
|
|
72
|
+
export declare const readDb: Proxy;
|
|
41
73
|
export declare interface DatabaseQueryLogEvent {
|
|
42
74
|
query: {
|
|
43
75
|
sql: string
|
|
@@ -159,6 +191,7 @@ declare interface Db extends Pick<Required<RawQueryBuilder>, GenericPassthroughK
|
|
|
159
191
|
selectFromSub: (sub: any, alias: string) => FluentChain
|
|
160
192
|
select: (table: TableName, ...columns: string[]) => FluentChain
|
|
161
193
|
unsafe: (query: string, params?: any[]) => UnsafeReturn
|
|
194
|
+
read: Omit<Db, 'read'>
|
|
162
195
|
}
|
|
163
196
|
// The bun-query-builder types `unsafe()` as returning `Promise<any>`, but at
|
|
164
197
|
// runtime it returns a Bun SQL Statement that has `.execute()`. This interface
|
package/dist/utils.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { createQueryBuilder, registerPersistentQueryHooks, setConfig } from "@stacksjs/query-builder";
|
|
3
|
+
import { SQL } from "bun";
|
|
3
4
|
import { env as envVars } from "@stacksjs/env";
|
|
4
5
|
import { getConnectionDefaults } from "./defaults";
|
|
6
|
+
import { isMysqlWire, toQueryBuilderDialect } from "./dialect";
|
|
7
|
+
import { contextInTransaction, markContextWrote, resolveReplicaConnection, selectReplica, shouldRouteToReplica, withTransactionContext } from "./replicas";
|
|
5
8
|
import { aggregateFunctions } from "./types";
|
|
6
9
|
const sqliteDefaults = getConnectionDefaults("sqlite", envVars), mysqlDefaults = getConnectionDefaults("mysql", envVars), postgresDefaults = getConnectionDefaults("postgres", envVars);
|
|
7
10
|
let appEnv = envVars.APP_ENV || "local", dbDriver = envVars.DB_CONNECTION || "sqlite", dbConfig = {
|
|
@@ -9,6 +12,7 @@ let appEnv = envVars.APP_ENV || "local", dbDriver = envVars.DB_CONNECTION || "sq
|
|
|
9
12
|
sqlite: { database: sqliteDefaults.database, prefix: "" },
|
|
10
13
|
mysql: { name: mysqlDefaults.database, host: mysqlDefaults.host, username: mysqlDefaults.username, password: mysqlDefaults.password, port: mysqlDefaults.port, prefix: "" },
|
|
11
14
|
singlestore: { name: mysqlDefaults.database, host: mysqlDefaults.host, username: mysqlDefaults.username, password: mysqlDefaults.password, port: mysqlDefaults.port, prefix: "" },
|
|
15
|
+
vitess: { name: mysqlDefaults.database, host: mysqlDefaults.host, username: mysqlDefaults.username, password: mysqlDefaults.password, port: 15306, prefix: "" },
|
|
12
16
|
postgres: { name: postgresDefaults.database, host: postgresDefaults.host, username: postgresDefaults.username, password: postgresDefaults.password, port: postgresDefaults.port, prefix: "" }
|
|
13
17
|
}
|
|
14
18
|
}, dbConfigLockTail = Promise.resolve();
|
|
@@ -29,6 +33,7 @@ export function initializeDbConfig(config) {
|
|
|
29
33
|
dbConfig = config.database;
|
|
30
34
|
updateQueryBuilderConfig();
|
|
31
35
|
_dbInstance = null;
|
|
36
|
+
_replicaInstances = new Map;
|
|
32
37
|
}
|
|
33
38
|
function getEnv() {
|
|
34
39
|
return appEnv;
|
|
@@ -40,16 +45,7 @@ function getDatabaseConfig() {
|
|
|
40
45
|
return dbConfig;
|
|
41
46
|
}
|
|
42
47
|
function getDialect() {
|
|
43
|
-
|
|
44
|
-
if (driver === "sqlite")
|
|
45
|
-
return "sqlite";
|
|
46
|
-
if (driver === "mysql")
|
|
47
|
-
return "mysql";
|
|
48
|
-
if (driver === "singlestore")
|
|
49
|
-
return "singlestore";
|
|
50
|
-
if (driver === "postgres")
|
|
51
|
-
return "postgres";
|
|
52
|
-
return "sqlite";
|
|
48
|
+
return toQueryBuilderDialect(getDriver());
|
|
53
49
|
}
|
|
54
50
|
function getDbConfig() {
|
|
55
51
|
const driver = getDriver(), database = getDatabaseConfig(), env = getEnv();
|
|
@@ -75,6 +71,14 @@ function getDbConfig() {
|
|
|
75
71
|
password: database.connections?.singlestore?.password ?? "",
|
|
76
72
|
port: database.connections?.singlestore?.port ?? 3306
|
|
77
73
|
};
|
|
74
|
+
if (driver === "vitess")
|
|
75
|
+
return {
|
|
76
|
+
database: database.connections?.vitess?.name || "stacks",
|
|
77
|
+
host: database.connections?.vitess?.host ?? "127.0.0.1",
|
|
78
|
+
username: database.connections?.vitess?.username ?? "root",
|
|
79
|
+
password: database.connections?.vitess?.password ?? "",
|
|
80
|
+
port: database.connections?.vitess?.port ?? 15306
|
|
81
|
+
};
|
|
78
82
|
if (driver === "postgres") {
|
|
79
83
|
const dbName = database.connections?.postgres?.name ?? "stacks";
|
|
80
84
|
return {
|
|
@@ -120,11 +124,40 @@ function forwardDatabaseQuery(event) {
|
|
|
120
124
|
import("./query-logger").then(({ logQuery }) => logQuery(event)).catch(() => {});
|
|
121
125
|
}
|
|
122
126
|
registerPersistentQueryHooks(createDatabaseQueryHooks(forwardDatabaseQuery));
|
|
127
|
+
function getPoolConfig() {
|
|
128
|
+
const driver = getDriver();
|
|
129
|
+
if (driver === "sqlite")
|
|
130
|
+
return;
|
|
131
|
+
return getDatabaseConfig().connections?.[driver]?.pool;
|
|
132
|
+
}
|
|
133
|
+
function getReplicas() {
|
|
134
|
+
const driver = getDriver();
|
|
135
|
+
if (driver === "sqlite")
|
|
136
|
+
return [];
|
|
137
|
+
return getDatabaseConfig().connections?.[driver]?.replicas ?? [];
|
|
138
|
+
}
|
|
139
|
+
function getReadPolicy() {
|
|
140
|
+
return getDatabaseConfig().reads ?? {};
|
|
141
|
+
}
|
|
142
|
+
function toBunPoolOptions(pool) {
|
|
143
|
+
if (!pool)
|
|
144
|
+
return {};
|
|
145
|
+
const options = {};
|
|
146
|
+
if (pool.max !== void 0)
|
|
147
|
+
options.max = pool.max;
|
|
148
|
+
if (pool.idleTimeoutMs !== void 0)
|
|
149
|
+
options.idleTimeout = Math.round(pool.idleTimeoutMs / 1000);
|
|
150
|
+
if (pool.acquireTimeoutMs !== void 0)
|
|
151
|
+
options.connectionTimeout = Math.round(pool.acquireTimeoutMs / 1000);
|
|
152
|
+
if (pool.maxLifetimeMs !== void 0)
|
|
153
|
+
options.maxLifetime = Math.round(pool.maxLifetimeMs / 1000);
|
|
154
|
+
return options;
|
|
155
|
+
}
|
|
123
156
|
function updateQueryBuilderConfig() {
|
|
124
|
-
const dialect = getDialect(), dbConfigForQb = getDbConfig();
|
|
157
|
+
const dialect = getDialect(), dbConfigForQb = getDbConfig(), pool = getPoolConfig();
|
|
125
158
|
setConfig({
|
|
126
159
|
dialect,
|
|
127
|
-
database: dbConfigForQb,
|
|
160
|
+
database: pool ? { ...dbConfigForQb, pool } : dbConfigForQb,
|
|
128
161
|
verbose: getEnv() !== "production",
|
|
129
162
|
snapshotDir: QB_SNAPSHOT_DIR,
|
|
130
163
|
timestamps: {
|
|
@@ -172,21 +205,77 @@ function applySqliteTransactionSerialization(instance) {
|
|
|
172
205
|
const original = instance.transaction.bind(instance);
|
|
173
206
|
instance.transaction = (...args) => serializeSqliteTransaction(() => original(...args));
|
|
174
207
|
}
|
|
208
|
+
function applyTransactionRoutingContext(instance) {
|
|
209
|
+
const original = instance.transaction.bind(instance);
|
|
210
|
+
instance.transaction = (...args) => withTransactionContext(() => original(...args));
|
|
211
|
+
}
|
|
175
212
|
function getDb() {
|
|
176
213
|
if (!_dbInstance) {
|
|
177
214
|
updateQueryBuilderConfig();
|
|
178
215
|
_dbInstance = createQueryBuilder();
|
|
179
216
|
if (getDialect() === "sqlite")
|
|
180
217
|
applySqliteTransactionSerialization(_dbInstance);
|
|
218
|
+
applyTransactionRoutingContext(_dbInstance);
|
|
181
219
|
}
|
|
182
220
|
return _dbInstance;
|
|
183
221
|
}
|
|
222
|
+
let _replicaInstances = new Map;
|
|
223
|
+
function getReplicaDb(replica) {
|
|
224
|
+
const primary = getDbConfig(), resolved = resolveReplicaConnection(replica, primary), key = `${resolved.host}:${resolved.port ?? ""}`, cached = _replicaInstances.get(key);
|
|
225
|
+
if (cached)
|
|
226
|
+
return cached;
|
|
227
|
+
const scheme = isMysqlWire(getDriver()) ? "mysql" : "postgres", auth = resolved.username ? `${encodeURIComponent(resolved.username)}:${encodeURIComponent(resolved.password ?? "")}@` : "", url = `${scheme}://${auth}${resolved.host}:${resolved.port}/${resolved.database}`, sql = new SQL({ url, ...toBunPoolOptions(getPoolConfig()) }), instance = createQueryBuilder({ sql });
|
|
228
|
+
_replicaInstances.set(key, instance);
|
|
229
|
+
return instance;
|
|
230
|
+
}
|
|
231
|
+
function getReadDb() {
|
|
232
|
+
const replicas = getReplicas(), policy = getReadPolicy();
|
|
233
|
+
if (!shouldRouteToReplica({ policy, replicas }))
|
|
234
|
+
return getDb();
|
|
235
|
+
const replica = selectReplica(replicas, policy.strategy);
|
|
236
|
+
return replica ? getReplicaDb(replica) : getDb();
|
|
237
|
+
}
|
|
238
|
+
function getExplicitReadDb() {
|
|
239
|
+
const replicas = getReplicas();
|
|
240
|
+
if (!replicas.length || contextInTransaction())
|
|
241
|
+
return getDb();
|
|
242
|
+
const replica = selectReplica(replicas, getReadPolicy().strategy);
|
|
243
|
+
return replica ? getReplicaDb(replica) : getDb();
|
|
244
|
+
}
|
|
245
|
+
const WRITE_ENTRY_POINTS = new Set([
|
|
246
|
+
"insertInto",
|
|
247
|
+
"updateTable",
|
|
248
|
+
"deleteFrom",
|
|
249
|
+
"create",
|
|
250
|
+
"createMany",
|
|
251
|
+
"insertOrIgnore",
|
|
252
|
+
"insertGetId",
|
|
253
|
+
"updateOrInsert",
|
|
254
|
+
"upsert"
|
|
255
|
+
]), READ_ENTRY_POINTS = new Set([
|
|
256
|
+
"selectFrom",
|
|
257
|
+
"selectFromSub",
|
|
258
|
+
"select"
|
|
259
|
+
]);
|
|
184
260
|
ensureConfigLoaded();
|
|
185
261
|
export const db = new Proxy({}, {
|
|
186
262
|
get(_target, prop) {
|
|
187
263
|
if (prop === "fn")
|
|
188
264
|
return aggregateFunctions;
|
|
189
|
-
|
|
265
|
+
if (prop === "read")
|
|
266
|
+
return readDb;
|
|
267
|
+
if (typeof prop === "string" && WRITE_ENTRY_POINTS.has(prop))
|
|
268
|
+
markContextWrote();
|
|
269
|
+
const instance = typeof prop === "string" && READ_ENTRY_POINTS.has(prop) ? getReadDb() : getDb(), value = instance[prop];
|
|
270
|
+
if (typeof value === "function")
|
|
271
|
+
return value.bind(instance);
|
|
272
|
+
return value;
|
|
273
|
+
}
|
|
274
|
+
}), readDb = new Proxy({}, {
|
|
275
|
+
get(_target, prop) {
|
|
276
|
+
if (prop === "fn")
|
|
277
|
+
return aggregateFunctions;
|
|
278
|
+
const instance = getExplicitReadDb(), value = instance[prop];
|
|
190
279
|
if (typeof value === "function")
|
|
191
280
|
return value.bind(instance);
|
|
192
281
|
return value;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { VindexType } from '@stacksjs/types';
|
|
2
|
+
/**
|
|
3
|
+
* Convert a model name to its conventional foreign key column.
|
|
4
|
+
*
|
|
5
|
+
* `User` -> `user_id`. Deliberately simple: it mirrors the convention the
|
|
6
|
+
* ORM's own relation resolution uses, and a model that diverges from it can
|
|
7
|
+
* state the column outright in `traits.sharding`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function foreignKeyForModel(modelName: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Decide how one model shards.
|
|
12
|
+
*
|
|
13
|
+
* Split out from `deriveVSchema` so the decision is testable on its own —
|
|
14
|
+
* it is the part with actual judgement in it, and the part a user will want
|
|
15
|
+
* to reason about when a query turns out to scatter.
|
|
16
|
+
*/
|
|
17
|
+
export declare function decideSharding(model: ShardableModel, tableByModel: Map<string, string>): ShardingDecision;
|
|
18
|
+
/**
|
|
19
|
+
* Build a keyspace VSchema from model definitions.
|
|
20
|
+
*
|
|
21
|
+
* Returns the decisions alongside the document because the document alone
|
|
22
|
+
* does not explain itself: a user reviewing a generated VSchema needs to
|
|
23
|
+
* know which tables co-locate and which will scatter, and that is exactly
|
|
24
|
+
* what is lost once it is serialized to JSON.
|
|
25
|
+
*/
|
|
26
|
+
export declare function deriveVSchema(models: ShardableModel[]): VSchemaResult;
|
|
27
|
+
/**
|
|
28
|
+
* Normalize a raw model definition into a `ShardableModel`.
|
|
29
|
+
*
|
|
30
|
+
* `belongsTo` accepts several shapes across the codebase (a bare string, an
|
|
31
|
+
* array of names, or an object keyed by model name), so this flattens them
|
|
32
|
+
* to the one form the derivation needs.
|
|
33
|
+
*/
|
|
34
|
+
export declare function toShardableModel(definition: any, table: string): ShardableModel;
|
|
35
|
+
/**
|
|
36
|
+
* Human-readable summary of the sharding decisions.
|
|
37
|
+
*
|
|
38
|
+
* Printed by `buddy generate:vschema` because the whole point of deriving
|
|
39
|
+
* the topology is lost if the user cannot see and challenge it.
|
|
40
|
+
*/
|
|
41
|
+
export declare function formatShardingReport(decisions: ShardingDecision[]): string;
|
|
42
|
+
/** The subset of a model definition this module needs. */
|
|
43
|
+
export declare interface ShardableModel {
|
|
44
|
+
name: string
|
|
45
|
+
table: string
|
|
46
|
+
belongsTo: string[]
|
|
47
|
+
useUuid: boolean
|
|
48
|
+
sharding?: {
|
|
49
|
+
column?: string
|
|
50
|
+
vindex?: VindexType
|
|
51
|
+
unsharded?: boolean
|
|
52
|
+
sequence?: string
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** A `column_vindexes` entry in the emitted VSchema. */
|
|
56
|
+
export declare interface ColumnVindex {
|
|
57
|
+
column: string
|
|
58
|
+
name: string
|
|
59
|
+
}
|
|
60
|
+
/** A table entry in the emitted VSchema. */
|
|
61
|
+
export declare interface VSchemaTable {
|
|
62
|
+
column_vindexes?: ColumnVindex[]
|
|
63
|
+
auto_increment?: { column: string, sequence: string }
|
|
64
|
+
type?: 'reference'
|
|
65
|
+
}
|
|
66
|
+
/** A Vitess keyspace VSchema document. */
|
|
67
|
+
export declare interface VSchema {
|
|
68
|
+
sharded: boolean
|
|
69
|
+
vindexes: Record<string, { type: string }>
|
|
70
|
+
tables: Record<string, VSchemaTable>
|
|
71
|
+
}
|
|
72
|
+
/** Why a table ended up sharded the way it did, for the CLI report. */
|
|
73
|
+
export declare interface ShardingDecision {
|
|
74
|
+
table: string
|
|
75
|
+
column: string | null
|
|
76
|
+
vindex: VindexType | null
|
|
77
|
+
reason: 'explicit' | 'co-located with parent' | 'root entity' | 'reference table'
|
|
78
|
+
parent?: string
|
|
79
|
+
warning?: string
|
|
80
|
+
}
|
|
81
|
+
export declare interface VSchemaResult {
|
|
82
|
+
vschema: VSchema
|
|
83
|
+
decisions: ShardingDecision[]
|
|
84
|
+
}
|