@stacksjs/database 0.70.256 → 0.70.258

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,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
- const driver = getDriver();
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
- const instance = getDb(), value = instance[prop];
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
+ }