@stacksjs/database 0.70.88 → 0.70.91
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.d.ts +60 -0
- package/dist/auth-tables.js +220 -0
- package/dist/class-seeder.d.ts +65 -0
- package/dist/class-seeder.js +116 -0
- package/dist/column.d.ts +17 -0
- package/dist/column.js +26 -0
- package/dist/custom/audits.d.ts +16 -0
- package/dist/custom/audits.js +57 -0
- package/dist/custom/errors.d.ts +1 -0
- package/dist/custom/errors.js +48 -0
- package/dist/custom/index.d.ts +3 -0
- package/dist/custom/index.js +3 -0
- package/dist/custom/jobs.d.ts +3 -0
- package/dist/custom/jobs.js +449 -0
- package/dist/database.d.ts +89 -0
- package/dist/database.js +178 -0
- package/dist/defaults.d.ts +48 -0
- package/dist/defaults.js +48 -0
- package/dist/driver-config.d.ts +149 -0
- package/dist/driver-config.js +144 -0
- package/dist/drivers/defaults/index.d.ts +2 -0
- package/dist/drivers/defaults/index.js +2 -0
- package/dist/drivers/defaults/passwords.d.ts +4 -0
- package/dist/drivers/defaults/passwords.js +106 -0
- package/dist/drivers/defaults/traits.d.ts +33 -0
- package/dist/drivers/defaults/traits.js +1125 -0
- package/dist/drivers/dynamodb.d.ts +200 -0
- package/dist/drivers/dynamodb.js +607 -0
- package/dist/drivers/helpers.d.ts +35 -0
- package/dist/drivers/helpers.js +206 -0
- package/dist/drivers/index.d.ts +16 -0
- package/dist/drivers/index.js +9 -0
- package/dist/drivers/mysql.d.ts +7 -0
- package/dist/drivers/mysql.js +322 -0
- package/dist/drivers/postgres.d.ts +7 -0
- package/dist/drivers/postgres.js +411 -0
- package/dist/drivers/sqlite.d.ts +20 -0
- package/dist/drivers/sqlite.js +397 -0
- package/dist/factory.d.ts +41 -0
- package/dist/factory.js +51 -0
- package/dist/fk-audit.d.ts +101 -0
- package/dist/fk-audit.js +181 -0
- package/dist/index.d.ts +149 -0
- package/dist/index.js +55 -0
- package/dist/migration-lock.d.ts +23 -0
- package/dist/migration-lock.js +143 -0
- package/dist/migrations.d.ts +76 -0
- package/dist/migrations.js +549 -0
- package/dist/notification-tables.d.ts +20 -0
- package/dist/notification-tables.js +54 -0
- package/dist/query-logger.d.ts +26 -0
- package/dist/query-logger.js +213 -0
- package/dist/query-parser.d.ts +4 -0
- package/dist/query-parser.js +93 -0
- package/dist/rbac-tables.d.ts +17 -0
- package/dist/rbac-tables.js +84 -0
- package/dist/safe-migrations.d.ts +72 -0
- package/dist/safe-migrations.js +59 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.js +10 -0
- package/dist/seed-scaffold.d.ts +34 -0
- package/dist/seed-scaffold.js +144 -0
- package/dist/seeder.d.ts +116 -0
- package/dist/seeder.js +363 -0
- package/dist/sql-helpers.d.ts +33 -0
- package/dist/sql-helpers.js +24 -0
- package/dist/table.d.ts +7 -0
- package/dist/table.js +26 -0
- package/dist/tools/setup.d.ts +1 -0
- package/dist/tools/setup.js +6 -0
- package/dist/transaction-context.d.ts +52 -0
- package/dist/transaction-context.js +62 -0
- package/dist/types.d.ts +151 -0
- package/dist/types.js +23 -0
- package/dist/unique-audit.d.ts +60 -0
- package/dist/unique-audit.js +174 -0
- package/dist/utils.d.ts +189 -0
- package/dist/utils.js +163 -0
- package/dist/uuid-columns.d.ts +22 -0
- package/dist/uuid-columns.js +68 -0
- package/dist/validators.d.ts +26 -0
- package/dist/validators.js +122 -0
- package/package.json +11 -11
package/dist/table.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { log } from "@stacksjs/logging";
|
|
2
|
+
import { Column } from "./column";
|
|
3
|
+
|
|
4
|
+
export class Table {
|
|
5
|
+
columns = [];
|
|
6
|
+
increments(name) {
|
|
7
|
+
const column = new Column(name, "integer", {
|
|
8
|
+
primaryKey: !0,
|
|
9
|
+
autoIncrement: !0
|
|
10
|
+
});
|
|
11
|
+
this.columns.push(column);
|
|
12
|
+
return column;
|
|
13
|
+
}
|
|
14
|
+
string(name, varchar = 255) {
|
|
15
|
+
const column = new Column(name, `varchar(${varchar})`);
|
|
16
|
+
this.columns.push(column);
|
|
17
|
+
return column;
|
|
18
|
+
}
|
|
19
|
+
timestamps() {
|
|
20
|
+
this.columns.push(new Column("created_at", "timestamp"));
|
|
21
|
+
this.columns.push(new Column("updated_at", "timestamp"));
|
|
22
|
+
}
|
|
23
|
+
execute() {
|
|
24
|
+
log.info(`Creating table with columns: ${this.columns.map((col) => col.name).join(", ")}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True if the current async context is inside an active transaction
|
|
3
|
+
* scope. Queue dispatch (and other side-effect emitters) read this
|
|
4
|
+
* to decide between immediate execution and buffering.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isInTransaction(): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Enqueue a callback to fire after the surrounding transaction
|
|
9
|
+
* commits. Returns:
|
|
10
|
+
* - `true` — buffered; caller should NOT execute the side-effect now
|
|
11
|
+
* - `false` — no active transaction; caller should execute immediately
|
|
12
|
+
*
|
|
13
|
+
* This is the low-level primitive. Higher-level facades (queue
|
|
14
|
+
* dispatch, mailer send, event emit) wrap it with their own
|
|
15
|
+
* "respect transaction context unless overridden" logic.
|
|
16
|
+
*/
|
|
17
|
+
export declare function enqueueAfterCommit(callback: AfterCommitCallback): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Run `fn` inside a transaction scope. Returns whatever `fn`
|
|
20
|
+
* returns. On success, fires every buffered after-commit callback
|
|
21
|
+
* in insertion order. On error, discards them — the transaction
|
|
22
|
+
* rolled back so the side-effects shouldn't happen.
|
|
23
|
+
*
|
|
24
|
+
* Used by `@stacksjs/orm`'s `transaction()` wrapper to thread the
|
|
25
|
+
* scope through user code. Apps don't call this directly.
|
|
26
|
+
*
|
|
27
|
+
* Nested calls reuse the outer scope rather than nesting — flush
|
|
28
|
+
* happens on the OUTERMOST commit, matching the savepoint
|
|
29
|
+
* semantics of every relational database. The depth counter is
|
|
30
|
+
* tracked so the outer call knows when it owns the flush.
|
|
31
|
+
*/
|
|
32
|
+
export declare function runInTransactionScope<T>(fn: () => Promise<T>, options?: { onError?: (err: unknown, index: number) => void }): Promise<T>;
|
|
33
|
+
/**
|
|
34
|
+
* Test-only escape hatch — manually flush the current scope's
|
|
35
|
+
* buffered callbacks without ending the transaction. Production
|
|
36
|
+
* code never needs this; tests use it to assert intermediate
|
|
37
|
+
* state. Returns the number of callbacks fired.
|
|
38
|
+
*/
|
|
39
|
+
export declare function __flushAfterCommitNow(): Promise<number>;
|
|
40
|
+
/**
|
|
41
|
+
* Test-only escape hatch — peek at the number of buffered
|
|
42
|
+
* callbacks without firing them. Returns 0 outside a scope.
|
|
43
|
+
*/
|
|
44
|
+
export declare function __pendingAfterCommitCount(): number;
|
|
45
|
+
/**
|
|
46
|
+
* One buffered side-effect waiting for the surrounding transaction
|
|
47
|
+
* to commit. Errors thrown during flush are NOT re-thrown — the
|
|
48
|
+
* transaction itself already committed, so failing the whole flow
|
|
49
|
+
* after-the-fact would corrupt the caller's mental model. Errors
|
|
50
|
+
* are logged via `onError` if the scope supplied one.
|
|
51
|
+
*/
|
|
52
|
+
declare type AfterCommitCallback = () => Promise<void> | void;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
const transactionStorage = new AsyncLocalStorage;
|
|
3
|
+
export function isInTransaction() {
|
|
4
|
+
return transactionStorage.getStore() !== void 0;
|
|
5
|
+
}
|
|
6
|
+
export function enqueueAfterCommit(callback) {
|
|
7
|
+
const scope = transactionStorage.getStore();
|
|
8
|
+
if (!scope)
|
|
9
|
+
return !1;
|
|
10
|
+
scope.pending.push(callback);
|
|
11
|
+
return !0;
|
|
12
|
+
}
|
|
13
|
+
export async function runInTransactionScope(fn, options = {}) {
|
|
14
|
+
const existing = transactionStorage.getStore();
|
|
15
|
+
if (existing) {
|
|
16
|
+
existing.depth += 1;
|
|
17
|
+
try {
|
|
18
|
+
return await fn();
|
|
19
|
+
} finally {
|
|
20
|
+
existing.depth -= 1;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const scope = {
|
|
24
|
+
pending: [],
|
|
25
|
+
depth: 1,
|
|
26
|
+
onError: options.onError
|
|
27
|
+
};
|
|
28
|
+
let result;
|
|
29
|
+
try {
|
|
30
|
+
result = await transactionStorage.run(scope, fn);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
scope.pending.length = 0;
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
await flushScope(scope);
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
async function flushScope(scope) {
|
|
39
|
+
for (let i = 0;i < scope.pending.length; i++)
|
|
40
|
+
try {
|
|
41
|
+
await scope.pending[i]();
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (scope.onError)
|
|
44
|
+
try {
|
|
45
|
+
scope.onError(err, i);
|
|
46
|
+
} catch {}
|
|
47
|
+
else
|
|
48
|
+
console.error("[transaction-context] after-commit callback threw:", err);
|
|
49
|
+
}
|
|
50
|
+
scope.pending.length = 0;
|
|
51
|
+
}
|
|
52
|
+
export async function __flushAfterCommitNow() {
|
|
53
|
+
const scope = transactionStorage.getStore();
|
|
54
|
+
if (!scope)
|
|
55
|
+
return 0;
|
|
56
|
+
const count = scope.pending.length;
|
|
57
|
+
await flushScope(scope);
|
|
58
|
+
return count;
|
|
59
|
+
}
|
|
60
|
+
export function __pendingAfterCommitCount() {
|
|
61
|
+
return transactionStorage.getStore()?.pending.length ?? 0;
|
|
62
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL template tag function.
|
|
3
|
+
* Creates parameterized SQL queries from template literals.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* const query = sql`SELECT * FROM users WHERE id = ${userId}`
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export declare function sql(strings: TemplateStringsArray, ...values: unknown[]): Sql;
|
|
11
|
+
/**
|
|
12
|
+
* Type for raw SQL expressions.
|
|
13
|
+
* Used when building dynamic SQL queries.
|
|
14
|
+
*/
|
|
15
|
+
export declare interface RawBuilder<T = unknown> {
|
|
16
|
+
readonly sql: string
|
|
17
|
+
readonly parameters?: unknown[]
|
|
18
|
+
readonly __result?: T
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* SQL template tag type.
|
|
22
|
+
* Used for tagged template literals that produce SQL.
|
|
23
|
+
*/
|
|
24
|
+
export declare interface Sql {
|
|
25
|
+
readonly sql: string
|
|
26
|
+
readonly parameters: unknown[]
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Reference to a column for use inside an expression. Returned by
|
|
30
|
+
* {@link StacksExpressionBuilder.ref} and accepted everywhere a value
|
|
31
|
+
* or column is expected (e.g. inside `sql\`\${ref} > 0\`\`).
|
|
32
|
+
*
|
|
33
|
+
* The shape matches `sql.ref()`'s return so a raw fragment from either
|
|
34
|
+
* source is interoperable.
|
|
35
|
+
*/
|
|
36
|
+
export declare interface ColumnRef {
|
|
37
|
+
readonly raw: string
|
|
38
|
+
as: (alias: string) => ColumnRef
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Fluent aggregate-function builder accessible via
|
|
42
|
+
* `eb.fn.count(...)`, `eb.fn.sum(...)`, etc. The chained `.as(name)`
|
|
43
|
+
* names the resulting column in the projection; `.filterWhere(...)`
|
|
44
|
+
* scopes the aggregate to a sub-population (`COUNT(*) FILTER (WHERE
|
|
45
|
+
* status = 'success')` style).
|
|
46
|
+
*/
|
|
47
|
+
export declare interface AggregateExpression {
|
|
48
|
+
as: (alias: string) => AggregateExpression
|
|
49
|
+
filterWhere: (column: string, op: ExpressionOperator | string, value: unknown) => AggregateExpression
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Aggregate-function accessor exposed on the expression builder.
|
|
53
|
+
*
|
|
54
|
+
* Covers the call sites in commerce today (`count`, `sum`, `avg`,
|
|
55
|
+
* `min`, `max`). Other Kysely-side aggregates (`countAll`,
|
|
56
|
+
* `coalesce`, etc.) can be added here as call sites surface; we
|
|
57
|
+
* deliberately don't widen to "everything Kysely exposes" because
|
|
58
|
+
* that surface keeps growing and an `any`-typed escape hatch always
|
|
59
|
+
* exists (`eb.fn as any).newThing(...)`) if a one-off bypass is
|
|
60
|
+
* genuinely needed.
|
|
61
|
+
*/
|
|
62
|
+
export declare interface ExpressionFunctions {
|
|
63
|
+
countAll: () => AggregateExpression
|
|
64
|
+
count: (column: string) => AggregateExpression
|
|
65
|
+
sum: (column: string) => AggregateExpression
|
|
66
|
+
avg: (column: string) => AggregateExpression
|
|
67
|
+
min: (column: string) => AggregateExpression
|
|
68
|
+
max: (column: string) => AggregateExpression
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Minimal typed expression-builder surface for sub-query / inline-
|
|
72
|
+
* expression callbacks (stacksjs/stacks#1892, T-2 from #1875).
|
|
73
|
+
*
|
|
74
|
+
* Background: the framework's commerce module passed `(eb: any) => …`
|
|
75
|
+
* to `.where()` / `.select()` callbacks across 80+ sites. The `any`
|
|
76
|
+
* escape meant typos like `eb.compare(...)` (no such method — it's
|
|
77
|
+
* `cmpr`) only surfaced at runtime, and any later rename in
|
|
78
|
+
* bun-query-builder couldn't break here at type-check time.
|
|
79
|
+
*
|
|
80
|
+
* This interface declares the methods commerce actually uses today —
|
|
81
|
+
* `or`, `cmpr`, `ref`, `raw`, plus the `fn` aggregate accessor. It
|
|
82
|
+
* intentionally does NOT claim to be the full Kysely
|
|
83
|
+
* `ExpressionBuilder<DB, TB>` type:
|
|
84
|
+
*
|
|
85
|
+
* - Stacks's `Database` is still typed as `any` (no generated
|
|
86
|
+
* schema map yet) so the table-aware narrowing Kysely offers
|
|
87
|
+
* can't be expressed here yet.
|
|
88
|
+
* - bun-query-builder doesn't currently re-export its internal
|
|
89
|
+
* `ExpressionBuilder` type, so we can't alias to the canonical
|
|
90
|
+
* shape upstream.
|
|
91
|
+
*
|
|
92
|
+
* When either of those changes upstream, swap this interface's
|
|
93
|
+
* implementation in one place rather than re-typing every call site.
|
|
94
|
+
*/
|
|
95
|
+
export declare interface StacksExpressionBuilder {
|
|
96
|
+
(left: unknown, op: ExpressionOperator | string, right: unknown): unknown
|
|
97
|
+
or: (expressions: ReadonlyArray<unknown>) => unknown
|
|
98
|
+
and?: (expressions: ReadonlyArray<unknown>) => unknown
|
|
99
|
+
cmpr: (left: unknown, op: ExpressionOperator, right: unknown) => unknown
|
|
100
|
+
ref: (column: string) => ColumnRef
|
|
101
|
+
raw: (value: string) => ColumnRef
|
|
102
|
+
fn: ExpressionFunctions
|
|
103
|
+
readonly [extra: string]: unknown
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Database types - Compatibility layer
|
|
107
|
+
*
|
|
108
|
+
* These types provide backwards compatibility with code that
|
|
109
|
+
* previously used Kysely types. They work with bun-query-builder's
|
|
110
|
+
* native type system.
|
|
111
|
+
*/
|
|
112
|
+
/**
|
|
113
|
+
* Marks a column as auto-generated (e.g., auto-increment primary keys).
|
|
114
|
+
* When inserting, this field is optional. When selecting, it's required.
|
|
115
|
+
*/
|
|
116
|
+
export type Generated<T> = T;
|
|
117
|
+
/**
|
|
118
|
+
* Marks a column as always generated (computed columns).
|
|
119
|
+
* This field cannot be inserted or updated directly.
|
|
120
|
+
*/
|
|
121
|
+
export type GeneratedAlways<T> = T;
|
|
122
|
+
/**
|
|
123
|
+
* Utility type for insert operations.
|
|
124
|
+
* Makes Generated fields optional, keeps required fields required.
|
|
125
|
+
*/
|
|
126
|
+
export type Insertable<T> = {
|
|
127
|
+
[K in keyof T]?: T[K]
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Utility type for select operations.
|
|
131
|
+
* All fields are as defined in the table type.
|
|
132
|
+
*/
|
|
133
|
+
export type Selectable<T> = T;
|
|
134
|
+
/**
|
|
135
|
+
* Utility type for update operations.
|
|
136
|
+
* All fields are optional since you may update only some fields.
|
|
137
|
+
*/
|
|
138
|
+
export type Updateable<T> = Partial<T>;
|
|
139
|
+
/**
|
|
140
|
+
* Database type alias for backwards compatibility.
|
|
141
|
+
* Use the query builder from bun-query-builder instead.
|
|
142
|
+
*/
|
|
143
|
+
export type Database = any;
|
|
144
|
+
/**
|
|
145
|
+
* Comparison operator accepted by {@link StacksExpressionBuilder.cmpr}
|
|
146
|
+
* and friends. Mirrors the standard SQL operators the underlying
|
|
147
|
+
* Kysely-style builder supports.
|
|
148
|
+
*/
|
|
149
|
+
export type ExpressionOperator = | '=' | '!=' | '<>' | '<' | '<=' | '>' | '>='
|
|
150
|
+
| 'in' | 'not in' | 'is' | 'is not'
|
|
151
|
+
| 'like' | 'not like' | 'ilike' | 'not ilike';
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function sql(strings, ...values) {
|
|
2
|
+
const sqlParts = [], parameters = [];
|
|
3
|
+
for (let i = 0;i < strings.length; i++) {
|
|
4
|
+
sqlParts.push(strings[i]);
|
|
5
|
+
if (i < values.length)
|
|
6
|
+
if (values[i] && typeof values[i] === "object" && "raw" in values[i])
|
|
7
|
+
sqlParts.push(values[i].raw);
|
|
8
|
+
else {
|
|
9
|
+
sqlParts.push("?");
|
|
10
|
+
parameters.push(values[i]);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
sql: sqlParts.join(""),
|
|
15
|
+
parameters
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
sql.raw = function raw(value) {
|
|
19
|
+
return { raw: value };
|
|
20
|
+
};
|
|
21
|
+
sql.ref = function ref(column) {
|
|
22
|
+
return { raw: column };
|
|
23
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walk every model file (user + framework defaults) and return the
|
|
3
|
+
* full list of declared unique constraints — both single-column
|
|
4
|
+
* `unique: true` attributes and `unique: true` composite indexes.
|
|
5
|
+
*/
|
|
6
|
+
export declare function getDeclaredUniques(): Promise<DeclaredUnique[]>;
|
|
7
|
+
/**
|
|
8
|
+
* Query the live database for every UNIQUE index, returning a
|
|
9
|
+
* normalised `{ table, name, columns }` shape. Dialect-aware: PRAGMA
|
|
10
|
+
* on SQLite, information_schema / pg_index on MySQL / PostgreSQL.
|
|
11
|
+
*
|
|
12
|
+
* The optional `dialect` parameter is the test seam — production code
|
|
13
|
+
* leaves it undefined so it's derived from `DB_CONNECTION`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function getLiveUniqueIndexes(dialect?: Dialect): Promise<LiveUniqueIndex[]>;
|
|
16
|
+
/**
|
|
17
|
+
* Diff declared unique constraints against live UNIQUE indexes. A
|
|
18
|
+
* declared entry is satisfied iff some live unique index on the same
|
|
19
|
+
* table has an equal column SET (sorted, case-insensitive). Declared
|
|
20
|
+
* entries whose table is absent from the live DB go to `skippedTables`
|
|
21
|
+
* (feature-gated commerce/CMS models legitimately have no table yet)
|
|
22
|
+
* rather than `missing`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function auditUniqueIndexes(dialect?: Dialect): Promise<UniqueAuditResult>;
|
|
25
|
+
// stacksjs/stacks#1952 — Unique-index drift audit. Compares each
|
|
26
|
+
// model's declared uniqueness (`unique: true` attributes and
|
|
27
|
+
// `unique: true` composite indexes) against the UNIQUE indexes that
|
|
28
|
+
// actually exist in the live database.
|
|
29
|
+
//
|
|
30
|
+
// This is the `buddy doctor` companion to the migrate-side self-heal
|
|
31
|
+
// (#1952): `buddy migrate` already re-queues missing unique-index
|
|
32
|
+
// migrations, but that only fires when you run migrate, doesn't help
|
|
33
|
+
// apps scaffolded during the stub era (whose own repos carry
|
|
34
|
+
// `SELECT 1;` stub migrations), and hard-fails mid-migrate on
|
|
35
|
+
// pre-existing duplicate rows instead of reporting first. This audit
|
|
36
|
+
// surfaces the drift read-only, before you migrate.
|
|
37
|
+
//
|
|
38
|
+
// Matching is by COLUMN SET, never by index name — the generator
|
|
39
|
+
// emits names like `users_users_email_unique` while migration
|
|
40
|
+
// filenames say `users_email_unique`, so names are not stable.
|
|
41
|
+
export declare interface DeclaredUnique {
|
|
42
|
+
table: string
|
|
43
|
+
columns: string[]
|
|
44
|
+
model: string
|
|
45
|
+
source: 'attribute' | 'index'
|
|
46
|
+
indexName?: string
|
|
47
|
+
}
|
|
48
|
+
export declare interface LiveUniqueIndex {
|
|
49
|
+
table: string
|
|
50
|
+
name: string
|
|
51
|
+
columns: string[]
|
|
52
|
+
}
|
|
53
|
+
export declare interface UniqueAuditResult {
|
|
54
|
+
supported: boolean
|
|
55
|
+
declared: DeclaredUnique[]
|
|
56
|
+
live: LiveUniqueIndex[]
|
|
57
|
+
missing: DeclaredUnique[]
|
|
58
|
+
skippedTables: string[]
|
|
59
|
+
}
|
|
60
|
+
declare type Dialect = 'sqlite' | 'mysql' | 'postgres' | 'other';
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { path } from "@stacksjs/path";
|
|
2
|
+
import { plural, snakeCase } from "@stacksjs/strings";
|
|
3
|
+
import { safeGlob } from "./fk-audit";
|
|
4
|
+
export async function getDeclaredUniques() {
|
|
5
|
+
const modelFiles = [
|
|
6
|
+
...safeGlob(path.userModelsPath("*.ts")),
|
|
7
|
+
...safeGlob(path.storagePath("framework/defaults/app/Models/**/*.ts"))
|
|
8
|
+
], declared = [];
|
|
9
|
+
for (const modelFile of modelFiles) {
|
|
10
|
+
let model;
|
|
11
|
+
try {
|
|
12
|
+
model = (await import(modelFile)).default;
|
|
13
|
+
} catch {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (!model || typeof model !== "object")
|
|
17
|
+
continue;
|
|
18
|
+
const table = model.table || plural(snakeCase(model.name || "")), modelName = model.name || "", attributes = model.attributes;
|
|
19
|
+
if (attributes && typeof attributes === "object") {
|
|
20
|
+
for (const [field, attr] of Object.entries(attributes))
|
|
21
|
+
if (attr && typeof attr === "object" && attr.unique === !0)
|
|
22
|
+
declared.push({
|
|
23
|
+
table,
|
|
24
|
+
columns: [snakeCase(field)],
|
|
25
|
+
model: modelName,
|
|
26
|
+
source: "attribute"
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const indexes = model.indexes;
|
|
30
|
+
if (Array.isArray(indexes)) {
|
|
31
|
+
for (const index of indexes)
|
|
32
|
+
if (index && index.unique === !0 && Array.isArray(index.columns) && index.columns.length > 0)
|
|
33
|
+
declared.push({
|
|
34
|
+
table,
|
|
35
|
+
columns: index.columns.map((c) => snakeCase(c)),
|
|
36
|
+
model: modelName,
|
|
37
|
+
source: "index",
|
|
38
|
+
indexName: index.name
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return declared;
|
|
43
|
+
}
|
|
44
|
+
export async function getLiveUniqueIndexes(dialect) {
|
|
45
|
+
const { db } = await import("./utils"), d = dialect ?? await currentDialect();
|
|
46
|
+
if (d === "sqlite")
|
|
47
|
+
return getSqliteLiveUniques(db);
|
|
48
|
+
if (d === "mysql")
|
|
49
|
+
return getMysqlLiveUniques(db);
|
|
50
|
+
if (d === "postgres")
|
|
51
|
+
return getPostgresLiveUniques(db);
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
export async function auditUniqueIndexes(dialect) {
|
|
55
|
+
const d = dialect ?? await currentDialect();
|
|
56
|
+
if (d !== "sqlite" && d !== "mysql" && d !== "postgres")
|
|
57
|
+
return { supported: !1, declared: [], live: [], missing: [], skippedTables: [] };
|
|
58
|
+
const declared = await getDeclaredUniques(), live = await getLiveUniqueIndexes(d), liveTables = await getLiveTables(d), liveByTable = new Map;
|
|
59
|
+
for (const idx of live) {
|
|
60
|
+
const t = idx.table.toLowerCase(), key = columnSetKey(idx.columns);
|
|
61
|
+
if (!liveByTable.has(t))
|
|
62
|
+
liveByTable.set(t, new Set);
|
|
63
|
+
liveByTable.get(t).add(key);
|
|
64
|
+
}
|
|
65
|
+
const missing = [], skippedTables = new Set;
|
|
66
|
+
for (const decl of declared) {
|
|
67
|
+
const t = decl.table.toLowerCase();
|
|
68
|
+
if (!liveTables.has(t)) {
|
|
69
|
+
skippedTables.add(decl.table);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const key = columnSetKey(decl.columns);
|
|
73
|
+
if (!(liveByTable.get(t)?.has(key) ?? !1))
|
|
74
|
+
missing.push(decl);
|
|
75
|
+
}
|
|
76
|
+
return { supported: !0, declared, live, missing, skippedTables: [...skippedTables] };
|
|
77
|
+
}
|
|
78
|
+
async function getLiveTables(dialect) {
|
|
79
|
+
const { db } = await import("./utils");
|
|
80
|
+
let rows = [];
|
|
81
|
+
if (dialect === "sqlite") {
|
|
82
|
+
const r = await db.unsafe("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").execute();
|
|
83
|
+
rows = (Array.isArray(r) ? r : []).map((x) => x.name);
|
|
84
|
+
} else if (dialect === "mysql") {
|
|
85
|
+
const r = await db.unsafe("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()").execute();
|
|
86
|
+
rows = (Array.isArray(r) ? r : []).map((x) => x.name ?? x.TABLE_NAME);
|
|
87
|
+
} else if (dialect === "postgres") {
|
|
88
|
+
const r = await db.unsafe("SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public'").execute();
|
|
89
|
+
rows = (Array.isArray(r) ? r : []).map((x) => x.name ?? x.tablename);
|
|
90
|
+
}
|
|
91
|
+
return new Set(rows.filter((n) => typeof n === "string" && n.length > 0).map((n) => n.toLowerCase()));
|
|
92
|
+
}
|
|
93
|
+
function columnSetKey(columns) {
|
|
94
|
+
return [...columns].map((c) => c.toLowerCase()).sort().join(",");
|
|
95
|
+
}
|
|
96
|
+
async function currentDialect() {
|
|
97
|
+
const driver = ((await import("@stacksjs/env")).env?.DB_CONNECTION ?? "sqlite").toLowerCase();
|
|
98
|
+
if (driver === "sqlite" || driver === "mysql" || driver === "postgres")
|
|
99
|
+
return driver;
|
|
100
|
+
return "other";
|
|
101
|
+
}
|
|
102
|
+
async function getSqliteLiveUniques(db) {
|
|
103
|
+
const tables = await db.unsafe("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").execute(), rows = Array.isArray(tables) ? tables : [], out = [];
|
|
104
|
+
for (const row of rows) {
|
|
105
|
+
const table = row.name;
|
|
106
|
+
if (!table)
|
|
107
|
+
continue;
|
|
108
|
+
if (!/^[a-z_]\w*$/i.test(table))
|
|
109
|
+
continue;
|
|
110
|
+
const indexRows = await db.unsafe(`PRAGMA index_list("${table}")`).execute();
|
|
111
|
+
for (const idx of Array.isArray(indexRows) ? indexRows : []) {
|
|
112
|
+
const r = idx;
|
|
113
|
+
if (Number(r.unique) !== 1 || !r.name)
|
|
114
|
+
continue;
|
|
115
|
+
if (!/^[a-z_]\w*$/i.test(r.name))
|
|
116
|
+
continue;
|
|
117
|
+
const colRows = await db.unsafe(`PRAGMA index_info("${r.name}")`).execute(), columns = (Array.isArray(colRows) ? colRows : []).map((c) => String(c.name ?? "")).filter((c) => c.length > 0);
|
|
118
|
+
if (columns.length > 0)
|
|
119
|
+
out.push({ table, name: r.name, columns });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
async function getMysqlLiveUniques(db) {
|
|
125
|
+
const rows = await db.unsafe(`
|
|
126
|
+
SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX
|
|
127
|
+
FROM information_schema.STATISTICS
|
|
128
|
+
WHERE TABLE_SCHEMA = DATABASE()
|
|
129
|
+
AND NON_UNIQUE = 0
|
|
130
|
+
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX
|
|
131
|
+
`).execute();
|
|
132
|
+
return groupIndexRows(Array.isArray(rows) ? rows : [], (r) => ({
|
|
133
|
+
table: String(r.TABLE_NAME ?? r.table_name ?? ""),
|
|
134
|
+
name: String(r.INDEX_NAME ?? r.index_name ?? ""),
|
|
135
|
+
column: String(r.COLUMN_NAME ?? r.column_name ?? "")
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
async function getPostgresLiveUniques(db) {
|
|
139
|
+
const rows = await db.unsafe(`
|
|
140
|
+
SELECT
|
|
141
|
+
t.relname AS table_name,
|
|
142
|
+
ix.relname AS index_name,
|
|
143
|
+
a.attname AS column_name,
|
|
144
|
+
k.ord AS seq_in_index
|
|
145
|
+
FROM pg_index i
|
|
146
|
+
JOIN pg_class t ON t.oid = i.indrelid
|
|
147
|
+
JOIN pg_class ix ON ix.oid = i.indexrelid
|
|
148
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
149
|
+
JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord) ON true
|
|
150
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
|
|
151
|
+
WHERE i.indisunique = true
|
|
152
|
+
AND n.nspname = 'public'
|
|
153
|
+
ORDER BY table_name, index_name, seq_in_index
|
|
154
|
+
`).execute();
|
|
155
|
+
return groupIndexRows(Array.isArray(rows) ? rows : [], (r) => ({
|
|
156
|
+
table: String(r.table_name ?? ""),
|
|
157
|
+
name: String(r.index_name ?? ""),
|
|
158
|
+
column: String(r.column_name ?? "")
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
function groupIndexRows(rows, pick) {
|
|
162
|
+
const map = new Map;
|
|
163
|
+
for (const row of rows) {
|
|
164
|
+
const { table, name, column } = pick(row);
|
|
165
|
+
if (!table || !name || !column)
|
|
166
|
+
continue;
|
|
167
|
+
const key = `${table} ${name}`, existing = map.get(key);
|
|
168
|
+
if (existing)
|
|
169
|
+
existing.columns.push(column);
|
|
170
|
+
else
|
|
171
|
+
map.set(key, { table, name, columns: [column] });
|
|
172
|
+
}
|
|
173
|
+
return [...map.values()];
|
|
174
|
+
}
|