@hodela/site 0.3.1 → 0.4.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.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Luật cho từng câu lệnh trong migration. MỘT bản duy nhất: nền tảng (kiểm tra khi upload và khi build) và công cụ của developer
3
+ * (`hodela-dev check`) dùng chung gói này (subpath `migration-rules`), nên "máy dev báo qua" nghĩa là nền tảng cũng cho qua. Migration chỉ được tạo thêm, không phá dữ liệu.
4
+ * Đây là rào chắn chống tai nạn (agent lỡ DROP/DELETE), không phải ranh giới bảo mật:
5
+ * mỗi website chạy trên database riêng của nó.
6
+ */
7
+ import { type SqlDialect, type SqlStatement } from "./sql-split.js";
8
+ export type IssueCode = "bad_file_name" | "duplicate_number" | "file_too_large" | "syntax" | "statement_not_allowed" | "update_without_where" | "reserved_name" | "dialect_syntax" | "empty_file";
9
+ export type Issue = {
10
+ code: string;
11
+ message: string;
12
+ file?: string | undefined;
13
+ line?: number | undefined;
14
+ };
15
+ export declare const MIGRATION_NAME_RE: RegExp;
16
+ export declare const MAX_MIGRATION_BYTES: number;
17
+ export declare const MAX_MIGRATION_FILES = 200;
18
+ /** Kiểm tra một câu lệnh. Trả về null nếu hợp lệ. */
19
+ export declare function classifyStatement(file: string, stmt: SqlStatement, dialect: SqlDialect): Issue | null;
20
+ export type ParsedMigration = {
21
+ name: string;
22
+ number: string;
23
+ sql: string;
24
+ statements: SqlStatement[];
25
+ };
26
+ /** Tên file, kích thước, cú pháp, từng câu lệnh. Không chạm vào database. */
27
+ export declare function analyzeMigrations(files: Array<{
28
+ name: string;
29
+ sql: string;
30
+ }>, dialect: SqlDialect): {
31
+ migrations: ParsedMigration[];
32
+ issues: Issue[];
33
+ };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Luật cho từng câu lệnh trong migration. MỘT bản duy nhất: nền tảng (kiểm tra khi upload và khi build) và công cụ của developer
3
+ * (`hodela-dev check`) dùng chung gói này (subpath `migration-rules`), nên "máy dev báo qua" nghĩa là nền tảng cũng cho qua. Migration chỉ được tạo thêm, không phá dữ liệu.
4
+ * Đây là rào chắn chống tai nạn (agent lỡ DROP/DELETE), không phải ranh giới bảo mật:
5
+ * mỗi website chạy trên database riêng của nó.
6
+ */
7
+ import { splitSqlStatements, SqlSyntaxError } from "./sql-split.js";
8
+ export const MIGRATION_NAME_RE = /^(\d{4})_[a-z0-9_]+\.sql$/;
9
+ export const MAX_MIGRATION_BYTES = 256 * 1024;
10
+ export const MAX_MIGRATION_FILES = 200;
11
+ const RESERVED_NAME_RE = /^(_hodela_|sqlite_|_cf_|d1_)/i;
12
+ const HINT_DESTRUCTIVE = "Migration chỉ được thêm, không xóa dữ liệu hay cấu trúc. Muốn bỏ một cột hoặc bảng: ngừng dùng nó trong code và để nguyên trong database.";
13
+ function words(stmt) {
14
+ return stmt.tokens.filter((t) => t.kind === "word").map((t) => t.text);
15
+ }
16
+ function fail(file, stmt, code, message) {
17
+ return { code, message, file, line: stmt.line };
18
+ }
19
+ /** Kiểm tra một câu lệnh. Trả về null nếu hợp lệ. */
20
+ export function classifyStatement(file, stmt, dialect) {
21
+ const w = words(stmt);
22
+ const first = w[0] ?? "";
23
+ for (const t of stmt.tokens) {
24
+ if ((t.kind === "word" || t.kind === "ident") && RESERVED_NAME_RE.test(t.text)) {
25
+ return fail(file, stmt, "reserved_name", `Tên "${t.text}" do hệ thống giữ riêng, đừng tạo hay đụng tới.`);
26
+ }
27
+ }
28
+ switch (first) {
29
+ case "CREATE": {
30
+ let i = 1;
31
+ if (w[i] === "UNIQUE")
32
+ i += 1;
33
+ const what = w[i];
34
+ if (what === "TEMP" || what === "TEMPORARY") {
35
+ return fail(file, stmt, "statement_not_allowed", "Không dùng bảng tạm (TEMP) trong migration.");
36
+ }
37
+ if (what === "TABLE") {
38
+ if (dialect === "mysql" && /\bTEXT\b[^,)]*\b(PRIMARY|UNIQUE)\b/i.test(stmt.sql)) {
39
+ return fail(file, stmt, "dialect_syntax", "MySQL không cho TEXT làm khóa chính hoặc UNIQUE. Dùng VARCHAR(255).");
40
+ }
41
+ return null;
42
+ }
43
+ if (what === "INDEX") {
44
+ if (dialect === "mysql" && w[i + 1] === "IF") {
45
+ return fail(file, stmt, "dialect_syntax", "MySQL không hỗ trợ CREATE INDEX IF NOT EXISTS. Khai báo index trong CREATE TABLE.");
46
+ }
47
+ return null;
48
+ }
49
+ return fail(file, stmt, "statement_not_allowed", `CREATE ${what ?? ""} không được phép. Chỉ CREATE TABLE và CREATE INDEX (không VIEW, TRIGGER, TYPE, EXTENSION...). Dùng TEXT/VARCHAR thay cho ENUM.`);
50
+ }
51
+ case "ALTER": {
52
+ const tableIdx = w.indexOf("TABLE");
53
+ if (w[1] !== "TABLE" || tableIdx < 0) {
54
+ return fail(file, stmt, "statement_not_allowed", "Chỉ ALTER TABLE được phép.");
55
+ }
56
+ const action = w.find((x, idx) => idx > tableIdx && (x === "ADD" || x === "RENAME" || x === "DROP" || x === "ALTER" || x === "MODIFY" || x === "CHANGE"));
57
+ if (action === "ADD" || action === "RENAME")
58
+ return null;
59
+ if (action === "DROP")
60
+ return fail(file, stmt, "statement_not_allowed", `DROP không được phép. ${HINT_DESTRUCTIVE}`);
61
+ return fail(file, stmt, "statement_not_allowed", "Chỉ ALTER TABLE ... ADD (thêm cột) hoặc RENAME được phép. Muốn đổi kiểu cột: thêm cột mới, chép dữ liệu sang, rồi dùng cột mới.");
62
+ }
63
+ case "INSERT":
64
+ return null;
65
+ case "UPDATE":
66
+ if (!w.includes("WHERE")) {
67
+ return fail(file, stmt, "update_without_where", "UPDATE phải có WHERE để không sửa nhầm cả bảng.");
68
+ }
69
+ return null;
70
+ case "DROP":
71
+ case "DELETE":
72
+ case "TRUNCATE":
73
+ return fail(file, stmt, "statement_not_allowed", `${first} không được phép. ${HINT_DESTRUCTIVE}`);
74
+ default:
75
+ return fail(file, stmt, "statement_not_allowed", `${first || "(trống)"} không được phép trong migration. Chỉ CREATE TABLE/INDEX, ALTER TABLE ADD/RENAME, INSERT, UPDATE ... WHERE.`);
76
+ }
77
+ }
78
+ /** Mẫu lỗi cú pháp riêng của từng dialect mà không có DB thật để thử (MySQL). */
79
+ function dialectLint(file, stmt, dialect) {
80
+ if (dialect === "mysql") {
81
+ if (/\bAUTOINCREMENT\b/i.test(stmt.sql)) {
82
+ return fail(file, stmt, "dialect_syntax", "MySQL dùng AUTO_INCREMENT, không phải AUTOINCREMENT.");
83
+ }
84
+ if (stmt.tokens.some((t) => t.kind === "ident" && stmt.sql.includes(`"${t.text}"`))) {
85
+ return fail(file, stmt, "dialect_syntax", 'MySQL không dùng "tên" có nháy kép. Dùng `tên` hoặc không bọc.');
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+ /** Tên file, kích thước, cú pháp, từng câu lệnh. Không chạm vào database. */
91
+ export function analyzeMigrations(files, dialect) {
92
+ const issues = [];
93
+ const migrations = [];
94
+ if (files.length > MAX_MIGRATION_FILES) {
95
+ issues.push({ code: "file_too_large", message: `Quá ${MAX_MIGRATION_FILES} file migration.` });
96
+ }
97
+ const seenNumbers = new Map();
98
+ for (const f of [...files].sort((a, b) => a.name.localeCompare(b.name))) {
99
+ const m = MIGRATION_NAME_RE.exec(f.name);
100
+ if (!m) {
101
+ issues.push({
102
+ code: "bad_file_name",
103
+ file: f.name,
104
+ message: `Tên "${f.name}" sai định dạng NNNN_ten.sql (4 chữ số, chữ thường, số, gạch dưới). Ví dụ: 0002_add_phone.sql.`,
105
+ });
106
+ continue;
107
+ }
108
+ const number = m[1];
109
+ const dup = seenNumbers.get(number);
110
+ if (dup) {
111
+ issues.push({
112
+ code: "duplicate_number",
113
+ file: f.name,
114
+ message: `Số ${number} bị trùng với ${dup}. Mỗi migration cần số riêng, tăng dần.`,
115
+ });
116
+ continue;
117
+ }
118
+ seenNumbers.set(number, f.name);
119
+ if (new TextEncoder().encode(f.sql).byteLength > MAX_MIGRATION_BYTES) {
120
+ issues.push({ code: "file_too_large", file: f.name, message: `Migration ${f.name} quá lớn.` });
121
+ continue;
122
+ }
123
+ let statements;
124
+ try {
125
+ statements = splitSqlStatements(f.sql, dialect);
126
+ }
127
+ catch (e) {
128
+ issues.push({
129
+ code: "syntax",
130
+ file: f.name,
131
+ line: e instanceof SqlSyntaxError ? e.line : undefined,
132
+ message: `${f.name}: ${e instanceof Error ? e.message : "lỗi cú pháp"}`,
133
+ });
134
+ continue;
135
+ }
136
+ if (statements.length === 0) {
137
+ issues.push({ code: "empty_file", file: f.name, message: `Migration ${f.name} không có câu lệnh nào.` });
138
+ continue;
139
+ }
140
+ for (const stmt of statements) {
141
+ const bad = classifyStatement(f.name, stmt, dialect) ?? dialectLint(f.name, stmt, dialect);
142
+ if (bad)
143
+ issues.push({ ...bad, message: `${f.name} dòng ${stmt.line}: ${bad.message}` });
144
+ }
145
+ migrations.push({ name: f.name, number, sql: f.sql, statements });
146
+ }
147
+ return { migrations, issues };
148
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hodela/site",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -19,6 +19,10 @@
19
19
  "types": "./dist/sql-split.d.ts",
20
20
  "import": "./dist/sql-split.js"
21
21
  },
22
+ "./migration-rules": {
23
+ "types": "./dist/migration-rules.d.ts",
24
+ "import": "./dist/migration-rules.js"
25
+ },
22
26
  "./admin-token": {
23
27
  "types": "./dist/admin-token.d.ts",
24
28
  "import": "./dist/admin-token.js"