@easy-cms/drizzle 0.1.0
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/LICENSE +21 -0
- package/README.md +6 -0
- package/dist/index.d.ts +157 -0
- package/dist/index.js +892 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kanawoot K.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# @easy-cms/drizzle
|
|
2
|
+
|
|
3
|
+
Internal building blocks shared by the Easy CMS database adapters
|
|
4
|
+
([`@easy-cms/db-sqlite`](../db-sqlite), [`@easy-cms/db-postgres`](../db-postgres)):
|
|
5
|
+
tables built from the config, `where` → SQL, documents with child tables, and migrations.
|
|
6
|
+
You don't need to install it directly.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { DatabaseInitArgs, Database, CollectionConfig, Field, ResolvedConfig, Logger } from '@easy-cms/core';
|
|
2
|
+
import { SQL } from 'drizzle-orm';
|
|
3
|
+
|
|
4
|
+
type AnyTable = any;
|
|
5
|
+
type AnyColumn = any;
|
|
6
|
+
type ColumnBuilder = any;
|
|
7
|
+
type IndexBuilder = any;
|
|
8
|
+
/** A Drizzle database or transaction (libSQL, postgres.js, PGlite…). */
|
|
9
|
+
type DrizzleDb = any;
|
|
10
|
+
/** drizzle-kit's snapshot JSON for one dialect. */
|
|
11
|
+
type Snapshot = Record<string, any>;
|
|
12
|
+
interface Statement {
|
|
13
|
+
readonly sql: string;
|
|
14
|
+
readonly params?: readonly unknown[];
|
|
15
|
+
}
|
|
16
|
+
/** Raw SQL access for the migrations table and migration files. */
|
|
17
|
+
interface SqlRunner {
|
|
18
|
+
query(sql: string, params?: readonly unknown[]): Promise<Record<string, unknown>[]>;
|
|
19
|
+
/** Runs the statements in one transaction: all or nothing. */
|
|
20
|
+
transaction(statements: readonly Statement[]): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
/** What differs between SQL dialects. */
|
|
23
|
+
interface Dialect {
|
|
24
|
+
readonly name: 'sqlite' | 'postgres';
|
|
25
|
+
table(name: string, columns: Record<string, ColumnBuilder>, indexes: (t: Record<string, AnyColumn>) => IndexBuilder[]): AnyTable;
|
|
26
|
+
index(name: string): {
|
|
27
|
+
on(column: AnyColumn): IndexBuilder;
|
|
28
|
+
};
|
|
29
|
+
uniqueIndex(name: string): {
|
|
30
|
+
on(column: AnyColumn): IndexBuilder;
|
|
31
|
+
};
|
|
32
|
+
/** Auto-incrementing integer primary key. */
|
|
33
|
+
serial(name: string): ColumnBuilder;
|
|
34
|
+
text(name: string): ColumnBuilder;
|
|
35
|
+
integer(name: string): ColumnBuilder;
|
|
36
|
+
/** Floating point. */
|
|
37
|
+
number(name: string): ColumnBuilder;
|
|
38
|
+
boolean(name: string): ColumnBuilder;
|
|
39
|
+
json(name: string): ColumnBuilder;
|
|
40
|
+
/** Case-insensitive substring match; `pattern` is already escaped and wrapped in `%`. */
|
|
41
|
+
like(column: AnyColumn, pattern: string): SQL;
|
|
42
|
+
/** Placeholder for the n-th parameter (1-based) in raw SQL. */
|
|
43
|
+
param(n: number): string;
|
|
44
|
+
/** CREATE TABLE IF NOT EXISTS for the migrations table. */
|
|
45
|
+
migrationsTableSQL(name: string): string;
|
|
46
|
+
/** drizzle-kit snapshot of the given tables. Loaded lazily (only dev and the CLI need it). */
|
|
47
|
+
snapshot(tables: Readonly<Record<string, AnyTable>>): Promise<Snapshot>;
|
|
48
|
+
/** SQL statements turning one snapshot into another. May prompt about renames when interactive. */
|
|
49
|
+
migration(prev: Snapshot, cur: Snapshot): Promise<string[]>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
declare const DEFAULT_TABLE_PREFIX = "ecms_";
|
|
53
|
+
declare const DEFAULT_MIGRATION_DIR = "easy-cms/migrations";
|
|
54
|
+
interface DrizzleAdapterOptions {
|
|
55
|
+
/** Prefix for every table Easy CMS creates. Default `ecms_`. */
|
|
56
|
+
readonly tablePrefix?: string;
|
|
57
|
+
/** Where migration files live, relative to the project root. Default `easy-cms/migrations`. */
|
|
58
|
+
readonly migrationDir?: string;
|
|
59
|
+
}
|
|
60
|
+
/** A driver connection an adapter opened. */
|
|
61
|
+
interface Connection {
|
|
62
|
+
readonly db: DrizzleDb;
|
|
63
|
+
readonly runner: SqlRunner;
|
|
64
|
+
close(): Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Builds the schema for the config, pushes or verifies it, and returns a `Database`.
|
|
68
|
+
* Closes the connection if startup fails.
|
|
69
|
+
*/
|
|
70
|
+
declare function connectDatabase(dialect: Dialect, connection: Connection, args: DatabaseInitArgs, options?: DrizzleAdapterOptions): Promise<Database>;
|
|
71
|
+
|
|
72
|
+
/** A scalar field stored as a column of this table. */
|
|
73
|
+
interface ColumnModel {
|
|
74
|
+
/** Path of the value inside the row's data, e.g. `['seo', 'title']`. */
|
|
75
|
+
readonly path: readonly string[];
|
|
76
|
+
readonly field: Field;
|
|
77
|
+
readonly column: string;
|
|
78
|
+
}
|
|
79
|
+
/** A child table holding array rows or hasMany values. */
|
|
80
|
+
interface ChildModel {
|
|
81
|
+
readonly kind: 'array' | 'values';
|
|
82
|
+
readonly path: readonly string[];
|
|
83
|
+
readonly field: Field;
|
|
84
|
+
readonly table: TableModel;
|
|
85
|
+
}
|
|
86
|
+
interface TableModel {
|
|
87
|
+
readonly name: string;
|
|
88
|
+
readonly kind: 'root' | 'array' | 'values';
|
|
89
|
+
readonly table: AnyTable;
|
|
90
|
+
readonly fields: readonly Field[];
|
|
91
|
+
readonly columns: readonly ColumnModel[];
|
|
92
|
+
readonly children: readonly ChildModel[];
|
|
93
|
+
/** The field whose values the `value` column holds, for `values` tables. */
|
|
94
|
+
readonly valueField?: Field;
|
|
95
|
+
}
|
|
96
|
+
interface CollectionModel {
|
|
97
|
+
readonly config: CollectionConfig;
|
|
98
|
+
readonly root: TableModel;
|
|
99
|
+
}
|
|
100
|
+
interface SchemaModel {
|
|
101
|
+
readonly collections: ReadonlyMap<string, CollectionModel>;
|
|
102
|
+
readonly globals: AnyTable;
|
|
103
|
+
/** Every table managed by snapshots, keyed by SQL name. */
|
|
104
|
+
readonly tables: Readonly<Record<string, AnyTable>>;
|
|
105
|
+
/** Changes whenever the generated schema changes. Used to detect missing migrations. */
|
|
106
|
+
readonly hash: string;
|
|
107
|
+
}
|
|
108
|
+
declare const snake: (name: string) => string;
|
|
109
|
+
/** Builds Drizzle tables for every collection and global in the config. */
|
|
110
|
+
declare function buildSchema(config: ResolvedConfig, prefix: string, dialect: Dialect): SchemaModel;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Unions two snapshots: every table, column and index from either side.
|
|
114
|
+
* Diffing prev → union → cur splits a change into "add only" and "drop only"
|
|
115
|
+
* steps, so drizzle-kit never has to ask whether something was renamed.
|
|
116
|
+
*/
|
|
117
|
+
declare function unionSnapshot(prev: Snapshot, cur: Snapshot): Snapshot;
|
|
118
|
+
interface MigratorOptions {
|
|
119
|
+
readonly dialect: Dialect;
|
|
120
|
+
readonly runner: SqlRunner;
|
|
121
|
+
readonly schema: SchemaModel;
|
|
122
|
+
readonly table: string;
|
|
123
|
+
readonly dir: string;
|
|
124
|
+
readonly logger: Logger;
|
|
125
|
+
readonly interactive: boolean;
|
|
126
|
+
}
|
|
127
|
+
declare class Migrator {
|
|
128
|
+
private readonly options;
|
|
129
|
+
constructor(options: MigratorOptions);
|
|
130
|
+
private get runner();
|
|
131
|
+
private q;
|
|
132
|
+
private snapshotOf;
|
|
133
|
+
/** SQL statements that turn `prev` into `cur`. Renames become drop + add unless interactive. */
|
|
134
|
+
private diff;
|
|
135
|
+
ensureTable(): Promise<void>;
|
|
136
|
+
private record;
|
|
137
|
+
private applied;
|
|
138
|
+
/** The schema the database is currently in, according to the migrations table. */
|
|
139
|
+
private currentSnapshot;
|
|
140
|
+
/** Development: bring the database in line with the config without migration files. */
|
|
141
|
+
push(): Promise<void>;
|
|
142
|
+
/** Production: refuse to start when the database or migrations lag behind the config. */
|
|
143
|
+
verify(): Promise<void>;
|
|
144
|
+
create(name: string): Promise<{
|
|
145
|
+
name: string;
|
|
146
|
+
file: string;
|
|
147
|
+
statements: string[];
|
|
148
|
+
} | null>;
|
|
149
|
+
migrate(): Promise<string[]>;
|
|
150
|
+
status(): Promise<{
|
|
151
|
+
name: string;
|
|
152
|
+
applied: boolean;
|
|
153
|
+
}[]>;
|
|
154
|
+
private files;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export { type AnyColumn, type AnyTable, type ColumnBuilder, type Connection, DEFAULT_MIGRATION_DIR, DEFAULT_TABLE_PREFIX, type Dialect, type DrizzleAdapterOptions, type DrizzleDb, type IndexBuilder, Migrator, type SchemaModel, type Snapshot, type SqlRunner, type Statement, buildSchema, connectDatabase, snake, unionSnapshot };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
// src/database.ts
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { QueryError as QueryError2 } from "@easy-cms/core";
|
|
4
|
+
import { count, eq as eq3, inArray as inArray3 } from "drizzle-orm";
|
|
5
|
+
|
|
6
|
+
// src/documents.ts
|
|
7
|
+
import { asc, eq, inArray } from "drizzle-orm";
|
|
8
|
+
var col = (model, name) => model.table[name];
|
|
9
|
+
function getAt(data, path) {
|
|
10
|
+
let current = data;
|
|
11
|
+
for (const key of path) {
|
|
12
|
+
if (typeof current !== "object" || current === null) return void 0;
|
|
13
|
+
current = current[key];
|
|
14
|
+
}
|
|
15
|
+
return current;
|
|
16
|
+
}
|
|
17
|
+
function setAt(data, path, value) {
|
|
18
|
+
let current = data;
|
|
19
|
+
for (const key of path.slice(0, -1)) {
|
|
20
|
+
if (typeof current[key] !== "object" || current[key] === null) current[key] = {};
|
|
21
|
+
current = current[key];
|
|
22
|
+
}
|
|
23
|
+
current[path[path.length - 1]] = value;
|
|
24
|
+
}
|
|
25
|
+
function rowToData(model, row) {
|
|
26
|
+
const data = {};
|
|
27
|
+
for (const column of model.columns) setAt(data, column.path, row[column.column] ?? null);
|
|
28
|
+
for (const child of model.children) setAt(data, child.path, []);
|
|
29
|
+
return data;
|
|
30
|
+
}
|
|
31
|
+
function dataToRow(model, data) {
|
|
32
|
+
const row = {};
|
|
33
|
+
for (const column of model.columns) row[column.column] = getAt(data, column.path) ?? null;
|
|
34
|
+
return row;
|
|
35
|
+
}
|
|
36
|
+
var CHUNK = 500;
|
|
37
|
+
function chunks(items) {
|
|
38
|
+
const out = [];
|
|
39
|
+
for (let i = 0; i < items.length; i += CHUNK) out.push(items.slice(i, i + CHUNK));
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
async function attachChildren(db, model, rows, docs) {
|
|
43
|
+
if (model.children.length === 0 || rows.length === 0) return;
|
|
44
|
+
const ids = rows.map((r) => r.id);
|
|
45
|
+
const indexById = new Map(ids.map((id, i) => [id, i]));
|
|
46
|
+
for (const child of model.children) {
|
|
47
|
+
const childRows = [];
|
|
48
|
+
for (const part of chunks(ids)) {
|
|
49
|
+
childRows.push(
|
|
50
|
+
...await db.select().from(child.table.table).where(inArray(col(child.table, "_parent_id"), part)).orderBy(asc(col(child.table, "_order")))
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const childDocs = child.kind === "array" ? await hydrate(db, child.table, childRows) : void 0;
|
|
54
|
+
childRows.forEach((childRow, i) => {
|
|
55
|
+
const index = indexById.get(childRow._parent_id);
|
|
56
|
+
if (index === void 0) return;
|
|
57
|
+
const list = getAt(docs[index], child.path);
|
|
58
|
+
list.push(child.kind === "array" ? { id: childRow.id, ...childDocs?.[i] } : childRow.value);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function hydrate(db, model, rows) {
|
|
63
|
+
const docs = rows.map((row) => rowToData(model, row));
|
|
64
|
+
await attachChildren(db, model, rows, docs);
|
|
65
|
+
return docs;
|
|
66
|
+
}
|
|
67
|
+
function toDocument(row, data, drafts) {
|
|
68
|
+
return {
|
|
69
|
+
id: row.id,
|
|
70
|
+
...data,
|
|
71
|
+
...drafts ? { status: row.status } : {},
|
|
72
|
+
createdAt: row.created_at,
|
|
73
|
+
updatedAt: row.updated_at
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
async function insertChildren(db, model, parentId, data) {
|
|
77
|
+
for (const child of model.children) {
|
|
78
|
+
const value = getAt(data, child.path);
|
|
79
|
+
if (!Array.isArray(value) || value.length === 0) continue;
|
|
80
|
+
await insertChild(db, child, parentId, value);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function insertChild(db, child, parentId, items) {
|
|
84
|
+
if (child.kind === "values") {
|
|
85
|
+
const rows = items.map((value, i) => ({ _parent_id: parentId, _order: i, value }));
|
|
86
|
+
for (const part of chunks(rows)) await db.insert(child.table.table).values(part);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
for (const [i, item] of items.entries()) {
|
|
90
|
+
const rowData = item;
|
|
91
|
+
const id = rowData.id;
|
|
92
|
+
await db.insert(child.table.table).values({ ...dataToRow(child.table, rowData), id, _parent_id: parentId, _order: i });
|
|
93
|
+
await insertChildren(db, child.table, id, rowData);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function deleteChildren(db, model, parentIds) {
|
|
97
|
+
if (parentIds.length === 0) return;
|
|
98
|
+
for (const child of model.children) {
|
|
99
|
+
for (const part of chunks(parentIds)) {
|
|
100
|
+
const parentCol = col(child.table, "_parent_id");
|
|
101
|
+
if (child.table.children.length > 0) {
|
|
102
|
+
const rows = await db.select({ id: col(child.table, "id") }).from(child.table.table).where(inArray(parentCol, part));
|
|
103
|
+
await deleteChildren(
|
|
104
|
+
db,
|
|
105
|
+
child.table,
|
|
106
|
+
rows.map((r) => r.id)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
await db.delete(child.table.table).where(inArray(parentCol, part));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function systemRow(values, drafts) {
|
|
114
|
+
return {
|
|
115
|
+
created_at: values.createdAt,
|
|
116
|
+
updated_at: values.updatedAt,
|
|
117
|
+
...drafts ? { status: values.status ?? "draft" } : {}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
async function insertDocument(db, model, data, drafts) {
|
|
121
|
+
const [inserted] = await db.insert(model.table).values({ ...dataToRow(model, data), ...systemRow(data, drafts) }).returning({ id: col(model, "id") });
|
|
122
|
+
const id = inserted?.id;
|
|
123
|
+
await insertChildren(db, model, id, data);
|
|
124
|
+
return id;
|
|
125
|
+
}
|
|
126
|
+
async function replaceDocument(db, model, id, data, drafts) {
|
|
127
|
+
await db.update(model.table).set({ ...dataToRow(model, data), ...systemRow(data, drafts) }).where(eq(col(model, "id"), id));
|
|
128
|
+
await deleteChildren(db, model, [id]);
|
|
129
|
+
await insertChildren(db, model, id, data);
|
|
130
|
+
}
|
|
131
|
+
async function deleteDocument(db, model, id) {
|
|
132
|
+
await deleteChildren(db, model, [id]);
|
|
133
|
+
await db.delete(model.table).where(eq(col(model, "id"), id));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/migrations.ts
|
|
137
|
+
import { mkdir, readdir, readFile, writeFile } from "fs/promises";
|
|
138
|
+
import { join } from "path";
|
|
139
|
+
import { SchemaError } from "@easy-cms/core";
|
|
140
|
+
var BREAKPOINT = "--> statement-breakpoint";
|
|
141
|
+
var TABLE_PARTS = [
|
|
142
|
+
"columns",
|
|
143
|
+
"indexes",
|
|
144
|
+
"foreignKeys",
|
|
145
|
+
"compositePrimaryKeys",
|
|
146
|
+
"uniqueConstraints",
|
|
147
|
+
"checkConstraints",
|
|
148
|
+
"policies"
|
|
149
|
+
];
|
|
150
|
+
function unionSnapshot(prev, cur) {
|
|
151
|
+
const merged = structuredClone(cur);
|
|
152
|
+
for (const [name, table] of Object.entries(prev.tables ?? {})) {
|
|
153
|
+
const target = merged.tables[name];
|
|
154
|
+
if (!target) {
|
|
155
|
+
merged.tables[name] = structuredClone(table);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
for (const key of TABLE_PARTS) {
|
|
159
|
+
if (!(key in table)) continue;
|
|
160
|
+
target[key] ??= {};
|
|
161
|
+
for (const [entry, value] of Object.entries(table[key] ?? {})) {
|
|
162
|
+
if (!(entry in target[key])) target[key][entry] = structuredClone(value);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return merged;
|
|
167
|
+
}
|
|
168
|
+
var DESTRUCTIVE = /\b(DROP TABLE|DROP COLUMN)\b|__new_/i;
|
|
169
|
+
var DEV_PUSH = "dev";
|
|
170
|
+
var Migrator = class {
|
|
171
|
+
constructor(options) {
|
|
172
|
+
this.options = options;
|
|
173
|
+
}
|
|
174
|
+
options;
|
|
175
|
+
get runner() {
|
|
176
|
+
return this.options.runner;
|
|
177
|
+
}
|
|
178
|
+
q(name) {
|
|
179
|
+
return this.options.dialect.name === "postgres" ? `"${name}"` : `\`${name}\``;
|
|
180
|
+
}
|
|
181
|
+
snapshotOf(schema) {
|
|
182
|
+
return this.options.dialect.snapshot(schema ? schema.tables : {});
|
|
183
|
+
}
|
|
184
|
+
/** SQL statements that turn `prev` into `cur`. Renames become drop + add unless interactive. */
|
|
185
|
+
async diff(prev, cur, interactive) {
|
|
186
|
+
const { dialect } = this.options;
|
|
187
|
+
if (interactive) return dialect.migration(prev, cur);
|
|
188
|
+
const union = unionSnapshot(prev, cur);
|
|
189
|
+
return [...await dialect.migration(prev, union), ...await dialect.migration(union, cur)];
|
|
190
|
+
}
|
|
191
|
+
async ensureTable() {
|
|
192
|
+
await this.runner.query(this.options.dialect.migrationsTableSQL(this.options.table));
|
|
193
|
+
}
|
|
194
|
+
record(name, hash, snapshot, upsert) {
|
|
195
|
+
const p = (n) => this.options.dialect.param(n);
|
|
196
|
+
const table = this.q(this.options.table);
|
|
197
|
+
const conflict = upsert ? " ON CONFLICT(name) DO UPDATE SET hash = excluded.hash, snapshot = excluded.snapshot, applied_at = excluded.applied_at" : "";
|
|
198
|
+
return {
|
|
199
|
+
sql: `INSERT INTO ${table} (name, hash, snapshot, applied_at) VALUES (${p(1)}, ${p(2)}, ${p(3)}, ${p(4)})${conflict}`,
|
|
200
|
+
params: [name, hash, JSON.stringify(snapshot), (/* @__PURE__ */ new Date()).toISOString()]
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
async applied() {
|
|
204
|
+
const rows = await this.runner.query(
|
|
205
|
+
`SELECT name, hash, snapshot FROM ${this.q(this.options.table)} ORDER BY id`
|
|
206
|
+
);
|
|
207
|
+
return rows.map((r) => ({
|
|
208
|
+
name: String(r.name),
|
|
209
|
+
hash: r.hash === null ? null : String(r.hash),
|
|
210
|
+
snapshot: r.snapshot === null ? null : String(r.snapshot)
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
/** The schema the database is currently in, according to the migrations table. */
|
|
214
|
+
async currentSnapshot(rows) {
|
|
215
|
+
const last = [...rows].reverse().find((r) => r.snapshot !== null);
|
|
216
|
+
return last?.snapshot ? JSON.parse(last.snapshot) : this.snapshotOf(null);
|
|
217
|
+
}
|
|
218
|
+
/** Development: bring the database in line with the config without migration files. */
|
|
219
|
+
async push() {
|
|
220
|
+
await this.ensureTable();
|
|
221
|
+
const rows = await this.applied();
|
|
222
|
+
const devRow = rows.find((r) => r.name === DEV_PUSH);
|
|
223
|
+
if (devRow?.hash === this.options.schema.hash) return;
|
|
224
|
+
const prev = await this.currentSnapshot(devRow ? [devRow] : rows);
|
|
225
|
+
const cur = await this.snapshotOf(this.options.schema);
|
|
226
|
+
const statements = await this.diff(prev, cur, false);
|
|
227
|
+
if (statements.some((s) => DESTRUCTIVE.test(s))) {
|
|
228
|
+
this.options.logger.warn("Schema push drops tables or columns; data in them is lost.");
|
|
229
|
+
}
|
|
230
|
+
await this.runner.transaction([
|
|
231
|
+
...statements.map((sql2) => ({ sql: sql2 })),
|
|
232
|
+
this.record(DEV_PUSH, this.options.schema.hash, cur, true)
|
|
233
|
+
]);
|
|
234
|
+
if (statements.length > 0)
|
|
235
|
+
this.options.logger.info(`Schema pushed (${statements.length} statements).`);
|
|
236
|
+
}
|
|
237
|
+
/** Production: refuse to start when the database or migrations lag behind the config. */
|
|
238
|
+
async verify() {
|
|
239
|
+
await this.ensureTable();
|
|
240
|
+
const files = await this.files();
|
|
241
|
+
const appliedNames = new Set((await this.applied()).map((r) => r.name));
|
|
242
|
+
const last = files.at(-1);
|
|
243
|
+
if (!last) {
|
|
244
|
+
throw new SchemaError(
|
|
245
|
+
`No migrations found in ${this.options.dir}.
|
|
246
|
+
\u2192 run \`easy-cms migrate:create init\` and deploy the generated files`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const pending = files.filter((f) => !appliedNames.has(f.name));
|
|
250
|
+
if (pending.length > 0) {
|
|
251
|
+
throw new SchemaError(
|
|
252
|
+
`${pending.length} pending migration(s): ${pending.map((f) => f.name).join(", ")}.
|
|
253
|
+
\u2192 run \`easy-cms migrate\``
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (last.hash !== this.options.schema.hash) {
|
|
257
|
+
throw new SchemaError(
|
|
258
|
+
"The config changed since the last migration.\n \u2192 run `easy-cms migrate:create <name>` and deploy it"
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
async create(name) {
|
|
263
|
+
const files = await this.files();
|
|
264
|
+
const last = files.at(-1);
|
|
265
|
+
const prev = last ? last.snapshot : await this.snapshotOf(null);
|
|
266
|
+
const cur = await this.snapshotOf(this.options.schema);
|
|
267
|
+
const statements = await this.diff(prev, cur, this.options.interactive);
|
|
268
|
+
if (statements.length === 0 && last?.hash === this.options.schema.hash) return null;
|
|
269
|
+
const safeName = name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "") || "migration";
|
|
270
|
+
const fullName = `${timestamp()}_${safeName}`;
|
|
271
|
+
await mkdir(this.options.dir, { recursive: true });
|
|
272
|
+
const file = join(this.options.dir, `${fullName}.sql`);
|
|
273
|
+
const header = `-- Easy CMS migration ${fullName} (${this.options.dialect.name})
|
|
274
|
+
-- Generated from the config; review before deploying.
|
|
275
|
+
`;
|
|
276
|
+
await writeFile(file, `${header}${statements.join(`
|
|
277
|
+
${BREAKPOINT}
|
|
278
|
+
`)}
|
|
279
|
+
`);
|
|
280
|
+
await writeFile(
|
|
281
|
+
join(this.options.dir, `${fullName}.json`),
|
|
282
|
+
`${JSON.stringify({ dialect: this.options.dialect.name, hash: this.options.schema.hash, snapshot: cur }, null, 2)}
|
|
283
|
+
`
|
|
284
|
+
);
|
|
285
|
+
return { name: fullName, file, statements };
|
|
286
|
+
}
|
|
287
|
+
async migrate() {
|
|
288
|
+
await this.ensureTable();
|
|
289
|
+
const rows = await this.applied();
|
|
290
|
+
if (rows.some((r) => r.name === DEV_PUSH)) {
|
|
291
|
+
throw new SchemaError(
|
|
292
|
+
"This database was set up by development schema push, so migrations cannot be applied to it.\n \u2192 run migrations against a fresh database, or reset the development database"
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
const appliedNames = new Set(rows.map((r) => r.name));
|
|
296
|
+
const pending = (await this.files()).filter((f) => !appliedNames.has(f.name));
|
|
297
|
+
for (const migration of pending) {
|
|
298
|
+
try {
|
|
299
|
+
await this.runner.transaction([
|
|
300
|
+
...migration.statements.map((sql2) => ({ sql: sql2 })),
|
|
301
|
+
this.record(migration.name, migration.hash, migration.snapshot, false)
|
|
302
|
+
]);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
throw new SchemaError(
|
|
305
|
+
`Migration ${migration.name} failed and was rolled back: ${error.message}`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
this.options.logger.info(`Applied ${migration.name}`);
|
|
309
|
+
}
|
|
310
|
+
return pending.map((m) => m.name);
|
|
311
|
+
}
|
|
312
|
+
async status() {
|
|
313
|
+
await this.ensureTable();
|
|
314
|
+
const appliedNames = new Set((await this.applied()).map((r) => r.name));
|
|
315
|
+
return (await this.files()).map((f) => ({ name: f.name, applied: appliedNames.has(f.name) }));
|
|
316
|
+
}
|
|
317
|
+
async files() {
|
|
318
|
+
let entries;
|
|
319
|
+
try {
|
|
320
|
+
entries = await readdir(this.options.dir);
|
|
321
|
+
} catch {
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
const names = entries.filter((e) => e.endsWith(".sql")).map((e) => e.slice(0, -4)).sort();
|
|
325
|
+
return Promise.all(
|
|
326
|
+
names.map(async (name) => {
|
|
327
|
+
const sqlText = await readFile(join(this.options.dir, `${name}.sql`), "utf8");
|
|
328
|
+
const meta = JSON.parse(await readFile(join(this.options.dir, `${name}.json`), "utf8"));
|
|
329
|
+
if (meta.dialect && meta.dialect !== this.options.dialect.name) {
|
|
330
|
+
throw new SchemaError(
|
|
331
|
+
`Migration ${name} was created for ${meta.dialect}, but the database is ${this.options.dialect.name}.`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
const statements = sqlText.split(BREAKPOINT).map(
|
|
335
|
+
(s) => s.split("\n").filter((line) => !line.startsWith("--")).join("\n").trim()
|
|
336
|
+
).filter((s) => s.length > 0);
|
|
337
|
+
return { name, statements, hash: meta.hash, snapshot: meta.snapshot };
|
|
338
|
+
})
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
function timestamp() {
|
|
343
|
+
const d = /* @__PURE__ */ new Date();
|
|
344
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
345
|
+
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/query.ts
|
|
349
|
+
import { parseId, QueryError } from "@easy-cms/core";
|
|
350
|
+
import {
|
|
351
|
+
and,
|
|
352
|
+
asc as asc2,
|
|
353
|
+
desc,
|
|
354
|
+
eq as eq2,
|
|
355
|
+
exists,
|
|
356
|
+
gt,
|
|
357
|
+
gte,
|
|
358
|
+
inArray as inArray2,
|
|
359
|
+
isNotNull,
|
|
360
|
+
isNull,
|
|
361
|
+
lt,
|
|
362
|
+
lte,
|
|
363
|
+
ne,
|
|
364
|
+
not,
|
|
365
|
+
notInArray,
|
|
366
|
+
or,
|
|
367
|
+
sql
|
|
368
|
+
} from "drizzle-orm";
|
|
369
|
+
var OPERATORS = /* @__PURE__ */ new Set([
|
|
370
|
+
"equals",
|
|
371
|
+
"not_equals",
|
|
372
|
+
"in",
|
|
373
|
+
"not_in",
|
|
374
|
+
"gt",
|
|
375
|
+
"gte",
|
|
376
|
+
"lt",
|
|
377
|
+
"lte",
|
|
378
|
+
"like",
|
|
379
|
+
"exists"
|
|
380
|
+
]);
|
|
381
|
+
var SYSTEM_COLUMNS = {
|
|
382
|
+
id: "id",
|
|
383
|
+
createdAt: "created_at",
|
|
384
|
+
updatedAt: "updated_at"
|
|
385
|
+
};
|
|
386
|
+
var col2 = (model, name) => model.table[name];
|
|
387
|
+
var ID_FIELD = { name: "id", type: "relationship", to: "" };
|
|
388
|
+
var LIKE_TYPES = /* @__PURE__ */ new Set(["text", "textarea", "email", "slug", "select"]);
|
|
389
|
+
function resolvePath(model, segments, drafts) {
|
|
390
|
+
const [first] = segments;
|
|
391
|
+
if (first === void 0) throw new QueryError("empty field path");
|
|
392
|
+
if (segments.length === 1) {
|
|
393
|
+
const system = SYSTEM_COLUMNS[first];
|
|
394
|
+
if (system && (model.kind === "root" || first === "id")) {
|
|
395
|
+
const field = first === "id" && model.kind !== "array" ? ID_FIELD : void 0;
|
|
396
|
+
return { kind: "column", column: col2(model, system), field };
|
|
397
|
+
}
|
|
398
|
+
if (first === "status" && drafts && model.kind === "root") {
|
|
399
|
+
return { kind: "column", column: col2(model, "status"), field: void 0 };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
let fields = model.fields;
|
|
403
|
+
const path = [];
|
|
404
|
+
for (let i = 0; i < segments.length; i++) {
|
|
405
|
+
const segment = segments[i];
|
|
406
|
+
const field = fields.find((f) => f.name === segment);
|
|
407
|
+
const shown = segments.slice(0, i + 1).join(".");
|
|
408
|
+
if (!field) throw new QueryError(`Unknown field "${shown}"`);
|
|
409
|
+
path.push(segment);
|
|
410
|
+
if (field.type === "group") {
|
|
411
|
+
fields = field.fields;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const child = model.children.find((c) => c.path.join(".") === path.join("."));
|
|
415
|
+
if (child) return { kind: "child", child, rest: segments.slice(i + 1) };
|
|
416
|
+
if (i < segments.length - 1) {
|
|
417
|
+
throw new QueryError(`Cannot query inside "${shown}" (${field.type} field)`);
|
|
418
|
+
}
|
|
419
|
+
if (field.type === "json" || field.type === "richText") {
|
|
420
|
+
throw new QueryError(`Cannot query "${shown}": ${field.type} fields are not queryable`);
|
|
421
|
+
}
|
|
422
|
+
const column = model.columns.find((c) => c.path.join(".") === path.join("."));
|
|
423
|
+
if (!column) throw new QueryError(`Unknown field "${shown}"`);
|
|
424
|
+
return { kind: "column", column: col2(model, column.column), field };
|
|
425
|
+
}
|
|
426
|
+
throw new QueryError(`"${segments.join(".")}" is a group; query one of its fields`);
|
|
427
|
+
}
|
|
428
|
+
function coerce(field, value) {
|
|
429
|
+
if (value instanceof Date) return value.toISOString();
|
|
430
|
+
if (field?.type === "relationship" || field?.type === "upload") return parseId(value) ?? value;
|
|
431
|
+
if (field?.type === "number" && typeof value === "string" && value.trim() !== "")
|
|
432
|
+
return Number(value);
|
|
433
|
+
if (field?.type === "boolean" && (value === "true" || value === "false")) return value === "true";
|
|
434
|
+
return value;
|
|
435
|
+
}
|
|
436
|
+
var escapeLike = (value) => value.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
437
|
+
function operatorSQL(dialect, column, field, op, raw, path) {
|
|
438
|
+
const list = (value2) => {
|
|
439
|
+
if (!Array.isArray(value2)) throw new QueryError(`"${path}.${op}" must be an array`);
|
|
440
|
+
return value2.map((v) => coerce(field, v));
|
|
441
|
+
};
|
|
442
|
+
const value = coerce(field, raw);
|
|
443
|
+
switch (op) {
|
|
444
|
+
case "equals":
|
|
445
|
+
return value === null ? isNull(column) : eq2(column, value);
|
|
446
|
+
case "not_equals":
|
|
447
|
+
return value === null ? isNotNull(column) : or(ne(column, value), isNull(column));
|
|
448
|
+
case "in": {
|
|
449
|
+
const values = list(raw);
|
|
450
|
+
return values.length === 0 ? sql`0 = 1` : inArray2(column, values);
|
|
451
|
+
}
|
|
452
|
+
case "not_in": {
|
|
453
|
+
const values = list(raw);
|
|
454
|
+
return values.length === 0 ? sql`1 = 1` : or(notInArray(column, values), isNull(column));
|
|
455
|
+
}
|
|
456
|
+
case "gt":
|
|
457
|
+
return gt(column, value);
|
|
458
|
+
case "gte":
|
|
459
|
+
return gte(column, value);
|
|
460
|
+
case "lt":
|
|
461
|
+
return lt(column, value);
|
|
462
|
+
case "lte":
|
|
463
|
+
return lte(column, value);
|
|
464
|
+
case "like":
|
|
465
|
+
if (typeof raw !== "string") throw new QueryError(`"${path}.like" must be a string`);
|
|
466
|
+
if (field && !LIKE_TYPES.has(field.type))
|
|
467
|
+
throw new QueryError(`"like" works on text fields, not "${path}"`);
|
|
468
|
+
return dialect.like(column, `%${escapeLike(raw)}%`);
|
|
469
|
+
case "exists":
|
|
470
|
+
return raw === false ? isNull(column) : isNotNull(column);
|
|
471
|
+
default:
|
|
472
|
+
throw new QueryError(`Unknown operator "${op}" on "${path}"`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
var WhereBuilder = class {
|
|
476
|
+
constructor(db, dialect, drafts) {
|
|
477
|
+
this.db = db;
|
|
478
|
+
this.dialect = dialect;
|
|
479
|
+
this.drafts = drafts;
|
|
480
|
+
}
|
|
481
|
+
db;
|
|
482
|
+
dialect;
|
|
483
|
+
drafts;
|
|
484
|
+
build(model, where) {
|
|
485
|
+
if (where === void 0) return void 0;
|
|
486
|
+
if (typeof where !== "object" || where === null || Array.isArray(where)) {
|
|
487
|
+
throw new QueryError("where must be an object");
|
|
488
|
+
}
|
|
489
|
+
const parts = [];
|
|
490
|
+
for (const [key, value] of Object.entries(where)) {
|
|
491
|
+
if (value === void 0) continue;
|
|
492
|
+
if (key === "and" || key === "or") {
|
|
493
|
+
if (!Array.isArray(value)) throw new QueryError(`"${key}" must be an array`);
|
|
494
|
+
const inner = value.map((w) => this.build(model, w)).filter((s) => s !== void 0);
|
|
495
|
+
if (inner.length > 0) parts.push(key === "and" ? and(...inner) : or(...inner));
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
parts.push(this.field(model, key, value));
|
|
499
|
+
}
|
|
500
|
+
return parts.length === 0 ? void 0 : parts.length === 1 ? parts[0] : and(...parts);
|
|
501
|
+
}
|
|
502
|
+
field(model, path, operators) {
|
|
503
|
+
if (typeof operators !== "object" || operators === null || Array.isArray(operators)) {
|
|
504
|
+
throw new QueryError(`"${path}" must be an object of operators, e.g. { equals: ... }`);
|
|
505
|
+
}
|
|
506
|
+
const entries = Object.entries(operators).filter(([, v]) => v !== void 0);
|
|
507
|
+
if (entries.length === 0) throw new QueryError(`"${path}" has no operator`);
|
|
508
|
+
const target = resolvePath(model, path.split("."), this.drafts && model.kind === "root");
|
|
509
|
+
const parts = entries.map(([op, value]) => {
|
|
510
|
+
if (!OPERATORS.has(op)) throw new QueryError(`Unknown operator "${op}" on "${path}"`);
|
|
511
|
+
if (target.kind === "column")
|
|
512
|
+
return operatorSQL(this.dialect, target.column, target.field, op, value, path);
|
|
513
|
+
return this.child(model, target.child, target.rest, op, value, path);
|
|
514
|
+
});
|
|
515
|
+
return parts.length === 1 ? parts[0] : and(...parts);
|
|
516
|
+
}
|
|
517
|
+
/** Conditions on array rows or hasMany values become (NOT) EXISTS subqueries. */
|
|
518
|
+
child(parent, child, rest, op, value, path) {
|
|
519
|
+
const table = child.table;
|
|
520
|
+
const link = eq2(col2(table, "_parent_id"), col2(parent, "id"));
|
|
521
|
+
const subquery = (condition) => exists(
|
|
522
|
+
this.db.select({ one: sql`1` }).from(table.table).where(condition ? and(link, condition) : link)
|
|
523
|
+
);
|
|
524
|
+
if (child.kind === "values") {
|
|
525
|
+
if (rest.length > 0) throw new QueryError(`Cannot query inside "${path}"`);
|
|
526
|
+
const valueColumn = col2(table, "value");
|
|
527
|
+
if (op === "exists") return value === false ? not(subquery(void 0)) : subquery(void 0);
|
|
528
|
+
if (op === "not_equals")
|
|
529
|
+
return not(
|
|
530
|
+
subquery(operatorSQL(this.dialect, valueColumn, table.valueField, "equals", value, path))
|
|
531
|
+
);
|
|
532
|
+
if (op === "not_in")
|
|
533
|
+
return not(
|
|
534
|
+
subquery(operatorSQL(this.dialect, valueColumn, table.valueField, "in", value, path))
|
|
535
|
+
);
|
|
536
|
+
return subquery(operatorSQL(this.dialect, valueColumn, table.valueField, op, value, path));
|
|
537
|
+
}
|
|
538
|
+
if (rest.length === 0) {
|
|
539
|
+
if (op !== "exists") throw new QueryError(`"${path}" is an array; query one of its fields`);
|
|
540
|
+
return value === false ? not(subquery(void 0)) : subquery(void 0);
|
|
541
|
+
}
|
|
542
|
+
return subquery(this.build(table, { [rest.join(".")]: { [op]: value } }));
|
|
543
|
+
}
|
|
544
|
+
orderBy(model, sort) {
|
|
545
|
+
const order = sort.map((entry) => {
|
|
546
|
+
const descending = entry.startsWith("-");
|
|
547
|
+
const path = descending ? entry.slice(1) : entry;
|
|
548
|
+
const target = resolvePath(model, path.split("."), this.drafts);
|
|
549
|
+
if (target.kind !== "column") throw new QueryError(`Cannot sort by "${path}"`);
|
|
550
|
+
return descending ? desc(target.column) : asc2(target.column);
|
|
551
|
+
});
|
|
552
|
+
order.push(desc(col2(model, "id")));
|
|
553
|
+
return order;
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
// src/schema.ts
|
|
558
|
+
import { createHash } from "crypto";
|
|
559
|
+
import { ConfigError } from "@easy-cms/core";
|
|
560
|
+
function migrationsTableName(prefix) {
|
|
561
|
+
return `${prefix}migrations`;
|
|
562
|
+
}
|
|
563
|
+
var snake = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").toLowerCase();
|
|
564
|
+
function columnKind(field) {
|
|
565
|
+
switch (field.type) {
|
|
566
|
+
case "number":
|
|
567
|
+
return "number";
|
|
568
|
+
case "boolean":
|
|
569
|
+
return "boolean";
|
|
570
|
+
case "json":
|
|
571
|
+
case "richText":
|
|
572
|
+
return "json";
|
|
573
|
+
case "upload":
|
|
574
|
+
case "relationship":
|
|
575
|
+
return "integer";
|
|
576
|
+
default:
|
|
577
|
+
return "text";
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
function buildSchema(config, prefix, dialect) {
|
|
581
|
+
const builder = {
|
|
582
|
+
dialect,
|
|
583
|
+
prefix,
|
|
584
|
+
tableNames: /* @__PURE__ */ new Set([migrationsTableName(prefix)]),
|
|
585
|
+
// The hash covers the logical schema only, so it stays stable across releases for the same config.
|
|
586
|
+
description: []
|
|
587
|
+
};
|
|
588
|
+
const collections = /* @__PURE__ */ new Map();
|
|
589
|
+
const tables = {};
|
|
590
|
+
for (const collection of config.collections) {
|
|
591
|
+
const root = buildTable(
|
|
592
|
+
builder,
|
|
593
|
+
`${prefix}${snake(collection.slug)}`,
|
|
594
|
+
"root",
|
|
595
|
+
collection.fields,
|
|
596
|
+
{
|
|
597
|
+
drafts: collection.drafts === true
|
|
598
|
+
}
|
|
599
|
+
);
|
|
600
|
+
collections.set(collection.slug, { config: collection, root });
|
|
601
|
+
collectTables(root, tables);
|
|
602
|
+
}
|
|
603
|
+
const globalsName = claimName(builder, `${prefix}globals`);
|
|
604
|
+
const globals = dialect.table(
|
|
605
|
+
globalsName,
|
|
606
|
+
{
|
|
607
|
+
slug: dialect.text("slug").primaryKey(),
|
|
608
|
+
data: dialect.json("data").notNull(),
|
|
609
|
+
status: dialect.text("status"),
|
|
610
|
+
updated_at: dialect.text("updated_at").notNull()
|
|
611
|
+
},
|
|
612
|
+
() => []
|
|
613
|
+
);
|
|
614
|
+
tables[globalsName] = globals;
|
|
615
|
+
builder.description.push(["globals", globalsName]);
|
|
616
|
+
const hash = createHash("sha256").update(JSON.stringify(builder.description)).digest("hex").slice(0, 16);
|
|
617
|
+
return { collections, globals, tables, hash };
|
|
618
|
+
}
|
|
619
|
+
function collectTables(model, into) {
|
|
620
|
+
into[model.name] = model.table;
|
|
621
|
+
for (const child of model.children) collectTables(child.table, into);
|
|
622
|
+
}
|
|
623
|
+
function claimName(builder, name) {
|
|
624
|
+
if (builder.tableNames.has(name)) {
|
|
625
|
+
throw new ConfigError([
|
|
626
|
+
{
|
|
627
|
+
path: name,
|
|
628
|
+
message: `two tables would be named "${name}"`,
|
|
629
|
+
hint: "rename one of the collections or fields"
|
|
630
|
+
}
|
|
631
|
+
]);
|
|
632
|
+
}
|
|
633
|
+
builder.tableNames.add(name);
|
|
634
|
+
return name;
|
|
635
|
+
}
|
|
636
|
+
function makeColumn(dialect, name, kind) {
|
|
637
|
+
switch (kind) {
|
|
638
|
+
case "number":
|
|
639
|
+
return dialect.number(name);
|
|
640
|
+
case "integer":
|
|
641
|
+
return dialect.integer(name);
|
|
642
|
+
case "boolean":
|
|
643
|
+
return dialect.boolean(name);
|
|
644
|
+
case "json":
|
|
645
|
+
return dialect.json(name);
|
|
646
|
+
case "text":
|
|
647
|
+
return dialect.text(name);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
function buildTable(builder, name, kind, fields, options) {
|
|
651
|
+
const { dialect } = builder;
|
|
652
|
+
claimName(builder, name);
|
|
653
|
+
const columns = {};
|
|
654
|
+
const described = [];
|
|
655
|
+
const columnModels = [];
|
|
656
|
+
const children = [];
|
|
657
|
+
const indexes = [];
|
|
658
|
+
const add = (column, builderColumn, desc2) => {
|
|
659
|
+
if (Object.hasOwn(columns, column)) {
|
|
660
|
+
throw new ConfigError([
|
|
661
|
+
{
|
|
662
|
+
path: `${name}.${column}`,
|
|
663
|
+
message: `two fields would use the column "${column}"`,
|
|
664
|
+
hint: "rename one of them"
|
|
665
|
+
}
|
|
666
|
+
]);
|
|
667
|
+
}
|
|
668
|
+
columns[column] = builderColumn;
|
|
669
|
+
described.push([column, desc2]);
|
|
670
|
+
};
|
|
671
|
+
if (kind === "root") {
|
|
672
|
+
add("id", dialect.serial("id"), "id:int");
|
|
673
|
+
add("created_at", dialect.text("created_at").notNull(), "text!");
|
|
674
|
+
add("updated_at", dialect.text("updated_at").notNull(), "text!");
|
|
675
|
+
if (options.drafts) add("status", dialect.text("status").notNull().default("draft"), "status");
|
|
676
|
+
indexes.push({ column: "created_at", unique: false });
|
|
677
|
+
} else {
|
|
678
|
+
const parentKind = options.parentIdKind ?? "integer";
|
|
679
|
+
add(
|
|
680
|
+
"id",
|
|
681
|
+
kind === "array" ? dialect.text("id").primaryKey() : dialect.serial("id"),
|
|
682
|
+
`id:${kind}`
|
|
683
|
+
);
|
|
684
|
+
add(
|
|
685
|
+
"_parent_id",
|
|
686
|
+
parentKind === "text" ? dialect.text("_parent_id").notNull() : dialect.integer("_parent_id").notNull(),
|
|
687
|
+
parentKind
|
|
688
|
+
);
|
|
689
|
+
add("_order", dialect.integer("_order").notNull(), "int!");
|
|
690
|
+
indexes.push({ column: "_parent_id", unique: false });
|
|
691
|
+
if (kind === "values" && options.valueField) {
|
|
692
|
+
const valueKind = columnKind(options.valueField);
|
|
693
|
+
add("value", makeColumn(dialect, "value", valueKind), valueKind);
|
|
694
|
+
indexes.push({ column: "value", unique: false });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const walk = (list, path, topLevel) => {
|
|
698
|
+
for (const field of list) {
|
|
699
|
+
const fieldPath = [...path, field.name];
|
|
700
|
+
const base = fieldPath.map(snake).join("_");
|
|
701
|
+
if (field.type === "group") {
|
|
702
|
+
walk(field.fields, fieldPath, false);
|
|
703
|
+
} else if (field.type === "array") {
|
|
704
|
+
const child = buildTable(builder, `${name}__${base}`, "array", field.fields, {
|
|
705
|
+
parentIdKind: kind === "array" ? "text" : "integer"
|
|
706
|
+
});
|
|
707
|
+
children.push({ kind: "array", path: fieldPath, field, table: child });
|
|
708
|
+
described.push([base, "array", child.name]);
|
|
709
|
+
} else if ((field.type === "select" || field.type === "relationship") && field.hasMany) {
|
|
710
|
+
const child = buildTable(builder, `${name}__${base}`, "values", [], {
|
|
711
|
+
parentIdKind: kind === "array" ? "text" : "integer",
|
|
712
|
+
valueField: field
|
|
713
|
+
});
|
|
714
|
+
children.push({ kind: "values", path: fieldPath, field, table: child });
|
|
715
|
+
described.push([base, "values", child.name]);
|
|
716
|
+
} else {
|
|
717
|
+
const kindOfColumn = columnKind(field);
|
|
718
|
+
add(base, makeColumn(dialect, base, kindOfColumn), kindOfColumn);
|
|
719
|
+
columnModels.push({ path: fieldPath, field, column: base });
|
|
720
|
+
if (topLevel && kind === "root" && (field.unique || field.type === "slug")) {
|
|
721
|
+
indexes.push({ column: base, unique: true });
|
|
722
|
+
} else if (field.index || field.unique || field.type === "relationship" || field.type === "upload") {
|
|
723
|
+
indexes.push({ column: base, unique: false });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
walk(fields, [], true);
|
|
729
|
+
const table = dialect.table(
|
|
730
|
+
name,
|
|
731
|
+
columns,
|
|
732
|
+
(t) => indexes.map(({ column, unique }) => {
|
|
733
|
+
const col3 = t[column];
|
|
734
|
+
return unique ? dialect.uniqueIndex(`${name}_${column}_unique`).on(col3) : dialect.index(`${name}_${column}_idx`).on(col3);
|
|
735
|
+
})
|
|
736
|
+
);
|
|
737
|
+
builder.description.push([name, kind, described, indexes]);
|
|
738
|
+
const model = { name, kind, table, fields, columns: columnModels, children };
|
|
739
|
+
return options.valueField ? { ...model, valueField: options.valueField } : model;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// src/database.ts
|
|
743
|
+
var DEFAULT_TABLE_PREFIX = "ecms_";
|
|
744
|
+
var DEFAULT_MIGRATION_DIR = "easy-cms/migrations";
|
|
745
|
+
async function connectDatabase(dialect, connection, args, options = {}) {
|
|
746
|
+
const prefix = options.tablePrefix ?? DEFAULT_TABLE_PREFIX;
|
|
747
|
+
try {
|
|
748
|
+
const schema = buildSchema(args.config, prefix, dialect);
|
|
749
|
+
const migrator = new Migrator({
|
|
750
|
+
dialect,
|
|
751
|
+
runner: connection.runner,
|
|
752
|
+
schema,
|
|
753
|
+
table: migrationsTableName(prefix),
|
|
754
|
+
dir: resolve(args.cwd, options.migrationDir ?? DEFAULT_MIGRATION_DIR),
|
|
755
|
+
logger: args.logger,
|
|
756
|
+
interactive: args.interactive ?? false
|
|
757
|
+
});
|
|
758
|
+
if (args.schema === "push") await migrator.push();
|
|
759
|
+
else if (args.schema === "verify") await migrator.verify();
|
|
760
|
+
return new DrizzleDatabase(dialect, connection, schema, migrator);
|
|
761
|
+
} catch (error) {
|
|
762
|
+
await connection.close().catch(() => {
|
|
763
|
+
});
|
|
764
|
+
throw error;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
var DrizzleDatabase = class {
|
|
768
|
+
constructor(dialect, connection, schema, migrator) {
|
|
769
|
+
this.connection = connection;
|
|
770
|
+
this.schema = schema;
|
|
771
|
+
this.migrator = migrator;
|
|
772
|
+
this.db = connection.db;
|
|
773
|
+
for (const [slug, model] of schema.collections) {
|
|
774
|
+
this.builders.set(slug, new WhereBuilder(this.db, dialect, model.config.drafts === true));
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
connection;
|
|
778
|
+
schema;
|
|
779
|
+
migrator;
|
|
780
|
+
builders = /* @__PURE__ */ new Map();
|
|
781
|
+
db;
|
|
782
|
+
model(slug) {
|
|
783
|
+
const model = this.schema.collections.get(slug);
|
|
784
|
+
if (!model) throw new QueryError2(`Unknown collection "${slug}"`);
|
|
785
|
+
return model;
|
|
786
|
+
}
|
|
787
|
+
builder(slug) {
|
|
788
|
+
return this.builders.get(slug);
|
|
789
|
+
}
|
|
790
|
+
where(slug, where) {
|
|
791
|
+
return this.builder(slug).build(this.model(slug).root, where);
|
|
792
|
+
}
|
|
793
|
+
async load(model, rows) {
|
|
794
|
+
const docs = await hydrate(this.db, model.root, rows);
|
|
795
|
+
return rows.map((row, i) => toDocument(row, docs[i] ?? {}, model.config.drafts === true));
|
|
796
|
+
}
|
|
797
|
+
async find(args) {
|
|
798
|
+
const model = this.model(args.collection);
|
|
799
|
+
const condition = this.where(args.collection, args.where);
|
|
800
|
+
const orderBy = this.builder(args.collection).orderBy(model.root, args.sort);
|
|
801
|
+
let query = this.db.select().from(model.root.table).where(condition).orderBy(...orderBy).$dynamic();
|
|
802
|
+
if (args.limit > 0) query = query.limit(args.limit).offset((args.page - 1) * args.limit);
|
|
803
|
+
const rows = await query;
|
|
804
|
+
const totalDocs = args.limit > 0 && (args.page > 1 || rows.length === args.limit) ? await this.count({ collection: args.collection, where: args.where }) : (args.page - 1) * args.limit + rows.length;
|
|
805
|
+
const totalPages = args.limit > 0 ? Math.max(1, Math.ceil(totalDocs / args.limit)) : 1;
|
|
806
|
+
return {
|
|
807
|
+
docs: await this.load(model, rows),
|
|
808
|
+
totalDocs,
|
|
809
|
+
limit: args.limit,
|
|
810
|
+
page: args.page,
|
|
811
|
+
totalPages,
|
|
812
|
+
hasNextPage: args.page < totalPages,
|
|
813
|
+
hasPrevPage: args.page > 1
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
async findById(args) {
|
|
817
|
+
const [doc] = await this.findByIds({ collection: args.collection, ids: [args.id] });
|
|
818
|
+
return doc ?? null;
|
|
819
|
+
}
|
|
820
|
+
async findByIds(args) {
|
|
821
|
+
if (args.ids.length === 0) return [];
|
|
822
|
+
const model = this.model(args.collection);
|
|
823
|
+
const rows = await this.db.select().from(model.root.table).where(inArray3(model.root.table.id, [...args.ids]));
|
|
824
|
+
return this.load(model, rows);
|
|
825
|
+
}
|
|
826
|
+
async count(args) {
|
|
827
|
+
const model = this.model(args.collection);
|
|
828
|
+
const [row] = await this.db.select({ total: count() }).from(model.root.table).where(this.where(args.collection, args.where));
|
|
829
|
+
return Number(row?.total ?? 0);
|
|
830
|
+
}
|
|
831
|
+
async create(args) {
|
|
832
|
+
const model = this.model(args.collection);
|
|
833
|
+
const id = await this.db.transaction(
|
|
834
|
+
(tx) => insertDocument(tx, model.root, args.data, model.config.drafts === true)
|
|
835
|
+
);
|
|
836
|
+
return await this.findById({ collection: args.collection, id });
|
|
837
|
+
}
|
|
838
|
+
async update(args) {
|
|
839
|
+
const model = this.model(args.collection);
|
|
840
|
+
await this.db.transaction(
|
|
841
|
+
(tx) => replaceDocument(tx, model.root, args.id, args.data, model.config.drafts === true)
|
|
842
|
+
);
|
|
843
|
+
return await this.findById({ collection: args.collection, id: args.id });
|
|
844
|
+
}
|
|
845
|
+
async delete(args) {
|
|
846
|
+
const model = this.model(args.collection);
|
|
847
|
+
await this.db.transaction((tx) => deleteDocument(tx, model.root, args.id));
|
|
848
|
+
}
|
|
849
|
+
async findGlobal(args) {
|
|
850
|
+
const table = this.schema.globals;
|
|
851
|
+
const [row] = await this.db.select().from(table).where(eq3(table.slug, args.slug));
|
|
852
|
+
if (!row) return null;
|
|
853
|
+
return {
|
|
854
|
+
...row.data,
|
|
855
|
+
...row.status ? { status: row.status } : {},
|
|
856
|
+
updatedAt: row.updated_at
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
async updateGlobal(args) {
|
|
860
|
+
const { status, updatedAt, ...data } = args.data;
|
|
861
|
+
const table = this.schema.globals;
|
|
862
|
+
const values = {
|
|
863
|
+
slug: args.slug,
|
|
864
|
+
data,
|
|
865
|
+
status: status ?? null,
|
|
866
|
+
updated_at: updatedAt
|
|
867
|
+
};
|
|
868
|
+
await this.db.insert(table).values(values).onConflictDoUpdate({ target: table.slug, set: values });
|
|
869
|
+
return await this.findGlobal({ slug: args.slug });
|
|
870
|
+
}
|
|
871
|
+
createMigration(args) {
|
|
872
|
+
return this.migrator.create(args.name);
|
|
873
|
+
}
|
|
874
|
+
migrate() {
|
|
875
|
+
return this.migrator.migrate();
|
|
876
|
+
}
|
|
877
|
+
migrationStatus() {
|
|
878
|
+
return this.migrator.status();
|
|
879
|
+
}
|
|
880
|
+
async destroy() {
|
|
881
|
+
await this.connection.close();
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
export {
|
|
885
|
+
DEFAULT_MIGRATION_DIR,
|
|
886
|
+
DEFAULT_TABLE_PREFIX,
|
|
887
|
+
Migrator,
|
|
888
|
+
buildSchema,
|
|
889
|
+
connectDatabase,
|
|
890
|
+
snake,
|
|
891
|
+
unionSnapshot
|
|
892
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@easy-cms/drizzle",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared Drizzle ORM layer for Easy CMS database adapters",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22.12"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"drizzle-kit": "0.31.11",
|
|
23
|
+
"drizzle-orm": "0.45.3"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@easy-cms/core": "^0.1.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@easy-cms/core": "^0.1.0"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/maritonx/easy-crm.git",
|
|
37
|
+
"directory": "packages/drizzle"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"easy-cms",
|
|
41
|
+
"drizzle"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsup",
|
|
45
|
+
"typecheck": "tsc --noEmit"
|
|
46
|
+
}
|
|
47
|
+
}
|