@goplusvn/core 0.1.48 → 0.1.50
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/bin/goerp-features.mjs +61 -0
- package/features/README.md +33 -0
- package/features/background-tasks/migrations/0001_init.sql +23 -0
- package/features/background-tasks/schema.prisma +22 -0
- package/package.json +7 -1
- package/scripts/feature-sync.mjs +145 -0
- package/src/crud/__tests__/filter-tree.test.ts +124 -0
- package/src/crud/lib/filter-tree.ts +150 -0
- package/src/crud/server-service.ts +14 -0
- package/src/crud/server.ts +1 -0
- package/src/features/__tests__/feature-sync.test.ts +135 -0
- package/src/types/index.ts +2 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI `goerp-features` — đồng bộ feature CÓ BẢNG DB của @goerp/core vào app.
|
|
3
|
+
//
|
|
4
|
+
// pnpm goerp-features status # feature/bước nào còn thiếu
|
|
5
|
+
// pnpm goerp-features sync # copy schema + materialize migrations
|
|
6
|
+
// pnpm goerp-features sync --only background-tasks
|
|
7
|
+
//
|
|
8
|
+
// Sau sync: chạy `pnpm prisma migrate deploy` (flow deploy hiện có) rồi
|
|
9
|
+
// `pnpm prisma generate`. SQL của core viết IDEMPOTENT nên app đã có bảng
|
|
10
|
+
// từ trước chạy lại vô hại.
|
|
11
|
+
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { featureStatus, syncFeatures } from "../scripts/feature-sync.mjs";
|
|
16
|
+
|
|
17
|
+
const featuresRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "features");
|
|
18
|
+
const appRoot = process.cwd();
|
|
19
|
+
|
|
20
|
+
const [, , command = "status", ...rest] = process.argv;
|
|
21
|
+
const onlyIdx = rest.indexOf("--only");
|
|
22
|
+
const only = onlyIdx >= 0 ? rest.slice(onlyIdx + 1).filter((a) => !a.startsWith("-")) : undefined;
|
|
23
|
+
|
|
24
|
+
if (command === "status") {
|
|
25
|
+
const statuses = featureStatus({ featuresRoot, appRoot });
|
|
26
|
+
if (statuses.length === 0) {
|
|
27
|
+
console.log("Không có feature nào trong @goerp/core/features.");
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
for (const f of statuses) {
|
|
31
|
+
const pending = f.steps.filter((s) => !s.materialized).length;
|
|
32
|
+
console.log(
|
|
33
|
+
`${pending === 0 && (!f.hasSchema || f.schemaSynced) ? "✓" : "•"} ${f.feature}` +
|
|
34
|
+
` (schema: ${f.hasSchema ? (f.schemaSynced ? "đã sync" : "CHƯA sync") : "—"};` +
|
|
35
|
+
` migrations: ${f.steps.length - pending}/${f.steps.length})`
|
|
36
|
+
);
|
|
37
|
+
for (const s of f.steps.filter((s) => !s.materialized)) {
|
|
38
|
+
console.log(` ↳ thiếu bước ${s.num}_${s.slug}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (command === "sync") {
|
|
45
|
+
const actions = syncFeatures({ featuresRoot, appRoot, only });
|
|
46
|
+
if (actions.length === 0) {
|
|
47
|
+
console.log("✓ Không có gì để sync — app đã đủ schema + migrations.");
|
|
48
|
+
} else {
|
|
49
|
+
for (const a of actions) {
|
|
50
|
+
if (a.type === "schema") console.log(`✓ schema ${a.feature} → ${a.target}`);
|
|
51
|
+
else console.log(`✓ migrate ${a.feature} + ${a.step} → prisma/migrations/${a.dir}`);
|
|
52
|
+
}
|
|
53
|
+
console.log(
|
|
54
|
+
"\nTiếp theo: pnpm prisma migrate deploy && pnpm prisma generate"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.error(`Lệnh không hợp lệ: ${command}. Dùng: status | sync [--only <feature>…]`);
|
|
61
|
+
process.exit(1);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @goerp/core features — tính năng có bảng DB
|
|
2
|
+
|
|
3
|
+
Mỗi thư mục con là một feature core ship kèm **bảng DB**. App tiêu thụ chạy:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm goerp-features status # xem thiếu gì
|
|
7
|
+
pnpm goerp-features sync # copy schema + materialize migrations
|
|
8
|
+
pnpm prisma migrate deploy && pnpm prisma generate
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`sync` copy `schema.prisma` → `prisma/schema/goerp-<feature>.prisma` (file
|
|
12
|
+
GENERATED — đừng sửa tay) và tạo thư mục migration
|
|
13
|
+
`<timestamp>_goerp_<feature>_<NNN>_<slug>/` cho bước nào app chưa có. App giữ
|
|
14
|
+
**một** hệ migration duy nhất (`prisma migrate deploy`), không có DDL lúc boot.
|
|
15
|
+
|
|
16
|
+
## Luật viết feature (tác giả core PHẢI theo)
|
|
17
|
+
|
|
18
|
+
1. **SQL idempotent tuyệt đối** — `CREATE TABLE IF NOT EXISTS`,
|
|
19
|
+
`CREATE INDEX IF NOT EXISTS`, `ALTER TABLE … ADD COLUMN IF NOT EXISTS`.
|
|
20
|
+
Lý do: app từng tự tạo bảng bằng migration tay (vinhhoa) chạy lại phải vô hại.
|
|
21
|
+
2. **Bước đã publish là BẤT BIẾN** — không sửa file `NNN_*.sql` cũ; đổi schema
|
|
22
|
+
thì thêm bước mới `NNN+1_*.sql` (và cập nhật `schema.prisma` fragment khớp).
|
|
23
|
+
3. Tên bước: `NNN_slug.sql` (NNN ≥ 3 chữ số, slug kebab-case).
|
|
24
|
+
4. `schema.prisma` fragment chỉ chứa model của feature này, dùng `@map`/`@@map`
|
|
25
|
+
snake_case như convention chung.
|
|
26
|
+
5. Feature cần seed/RBAC: ghi rõ trong README của feature — seed chạy phía app
|
|
27
|
+
(Permission Registry của app là nguồn sự thật quyền).
|
|
28
|
+
|
|
29
|
+
## Feature hiện có
|
|
30
|
+
|
|
31
|
+
- `background-tasks` — bảng `background_tasks` cho trung tâm tác vụ nền
|
|
32
|
+
(export/import chạy nền). Runtime hiện ở app (vinhhoa `src/server/tasks`);
|
|
33
|
+
sẽ dời lên core ở đợt F1.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- goerp feature: background-tasks — bước 0001 (idempotent: app đã tự tạo bảng
|
|
2
|
+
-- từ trước chạy lại vô hại; quy ước MỌI bước SQL của core đều IF NOT EXISTS).
|
|
3
|
+
CREATE TABLE IF NOT EXISTS "background_tasks" (
|
|
4
|
+
"id" TEXT NOT NULL,
|
|
5
|
+
"type" TEXT NOT NULL,
|
|
6
|
+
"title" TEXT NOT NULL,
|
|
7
|
+
"status" TEXT NOT NULL DEFAULT 'pending',
|
|
8
|
+
"progress" INTEGER NOT NULL DEFAULT 0,
|
|
9
|
+
"params" JSONB,
|
|
10
|
+
"result" JSONB,
|
|
11
|
+
"error" TEXT,
|
|
12
|
+
"created_by" TEXT NOT NULL,
|
|
13
|
+
"branch_id" TEXT,
|
|
14
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
15
|
+
"started_at" TIMESTAMP(3),
|
|
16
|
+
"finished_at" TIMESTAMP(3),
|
|
17
|
+
|
|
18
|
+
CONSTRAINT "background_tasks_pkey" PRIMARY KEY ("id")
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE INDEX IF NOT EXISTS "background_tasks_created_by_created_at_idx" ON "background_tasks"("created_by", "created_at");
|
|
22
|
+
|
|
23
|
+
CREATE INDEX IF NOT EXISTS "background_tasks_status_idx" ON "background_tasks"("status");
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Tác vụ nền (export/import lớn…): hàng đợi trong DB, worker in-process.
|
|
2
|
+
// status: pending → running → success | error.
|
|
3
|
+
// result: { fileKey?, fileName?, rowCount?, errorFileKey?, summary? }.
|
|
4
|
+
model BackgroundTask {
|
|
5
|
+
id String @id @default(cuid()) @map("id")
|
|
6
|
+
type String @map("type")
|
|
7
|
+
title String @map("title")
|
|
8
|
+
status String @default("pending") @map("status")
|
|
9
|
+
progress Int @default(0) @map("progress")
|
|
10
|
+
params Json? @map("params")
|
|
11
|
+
result Json? @map("result")
|
|
12
|
+
error String? @map("error")
|
|
13
|
+
createdBy String @map("created_by")
|
|
14
|
+
branchId String? @map("branch_id")
|
|
15
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
16
|
+
startedAt DateTime? @map("started_at")
|
|
17
|
+
finishedAt DateTime? @map("finished_at")
|
|
18
|
+
|
|
19
|
+
@@index([createdBy, createdAt])
|
|
20
|
+
@@index([status])
|
|
21
|
+
@@map("background_tasks")
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goplusvn/core",
|
|
3
3
|
"description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.50",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -11,8 +11,14 @@
|
|
|
11
11
|
"module": "./src/index.ts",
|
|
12
12
|
"types": "./src/index.ts",
|
|
13
13
|
"sideEffects": false,
|
|
14
|
+
"bin": {
|
|
15
|
+
"goerp-features": "./bin/goerp-features.mjs"
|
|
16
|
+
},
|
|
14
17
|
"files": [
|
|
15
18
|
"src",
|
|
19
|
+
"bin",
|
|
20
|
+
"scripts",
|
|
21
|
+
"features",
|
|
16
22
|
"README.md",
|
|
17
23
|
"CHANGELOG.md",
|
|
18
24
|
"PLATFORM.md"
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Feature sync — bộ máy cho CLI `goerp-features` (bin/goerp-features.mjs).
|
|
2
|
+
//
|
|
3
|
+
// BÀI TOÁN: @goerp/core ship tính năng CÓ BẢNG DB (background-tasks,
|
|
4
|
+
// notifications, error-logs…) qua npm; mỗi app tiêu thụ (vinhhoa, thingtodo,
|
|
5
|
+
// app init mới) trước đây phải chép tay schema + migration → thiếu sót, lệch.
|
|
6
|
+
//
|
|
7
|
+
// THIẾT KẾ (chọn "materialize vào prisma/migrations của app" thay vì ledger
|
|
8
|
+
// runtime kiểu NocoBase — không DDL lúc boot, app giữ MỘT hệ migration duy
|
|
9
|
+
// nhất `prisma migrate deploy`):
|
|
10
|
+
// - Core ship mỗi feature tại features/<name>/:
|
|
11
|
+
// schema.prisma — fragment model (copy vào schema dir của app)
|
|
12
|
+
// migrations/NNN_slug.sql — bước SQL đánh số, PHẢI viết IDEMPOTENT
|
|
13
|
+
// (CREATE TABLE IF NOT EXISTS…) để app đã có
|
|
14
|
+
// bảng từ trước (vinhhoa) chạy lại vô hại.
|
|
15
|
+
// - `sync`: copy fragment → prisma/schema/goerp-<feature>.prisma (đè, có
|
|
16
|
+
// header GENERATED) + với mỗi bước CHƯA materialize, tạo thư mục
|
|
17
|
+
// <timestamp>_goerp_<feature>_<NNN>_<slug>/migration.sql. Nhận bước đã
|
|
18
|
+
// materialize bằng ĐUÔI TÊN (bỏ qua timestamp) nên chạy lại idempotent.
|
|
19
|
+
// - `status`: liệt kê feature/bước, cái nào pending.
|
|
20
|
+
//
|
|
21
|
+
// Logic thuần (fs qua tham số path) để unit-test được bằng vitest trên tmp dir.
|
|
22
|
+
|
|
23
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
|
|
26
|
+
/** features/<name>/migrations/NNN_slug.sql → [{num, slug, file}] theo thứ tự. */
|
|
27
|
+
export function listFeatureSteps(featureDir) {
|
|
28
|
+
const dir = join(featureDir, "migrations");
|
|
29
|
+
if (!existsSync(dir)) return [];
|
|
30
|
+
return readdirSync(dir)
|
|
31
|
+
.filter((f) => /^\d{3,}_[a-z0-9-]+\.sql$/.test(f))
|
|
32
|
+
.sort()
|
|
33
|
+
.map((f) => {
|
|
34
|
+
const m = f.match(/^(\d{3,})_([a-z0-9-]+)\.sql$/);
|
|
35
|
+
return { num: m[1], slug: m[2], file: join(dir, f) };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Tên mọi feature trong thư mục features/ của core. */
|
|
40
|
+
export function listFeatures(featuresRoot) {
|
|
41
|
+
if (!existsSync(featuresRoot)) return [];
|
|
42
|
+
return readdirSync(featuresRoot, { withFileTypes: true })
|
|
43
|
+
.filter((e) => e.isDirectory())
|
|
44
|
+
.map((e) => e.name)
|
|
45
|
+
.sort();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Đuôi định danh của một bước — phần tên KHÔNG đổi giữa các app/timestamp. */
|
|
49
|
+
export function stepDirSuffix(feature, step) {
|
|
50
|
+
return `goerp_${feature.replace(/-/g, "_")}_${step.num}_${step.slug.replace(/-/g, "_")}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Bước đã được materialize vào prisma/migrations của app chưa? */
|
|
54
|
+
export function isStepMaterialized(appMigrationsDir, feature, step) {
|
|
55
|
+
if (!existsSync(appMigrationsDir)) return false;
|
|
56
|
+
const suffix = `_${stepDirSuffix(feature, step)}`;
|
|
57
|
+
return readdirSync(appMigrationsDir).some((d) => d.endsWith(suffix));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Timestamp prefix kiểu prisma (YYYYMMDDHHMMSS). `offset` giây cộng thêm để
|
|
62
|
+
* nhiều bước sinh trong cùng lần sync giữ đúng thứ tự.
|
|
63
|
+
*/
|
|
64
|
+
export function migrationTimestamp(now, offset = 0) {
|
|
65
|
+
const d = new Date(now.getTime() + offset * 1000);
|
|
66
|
+
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
67
|
+
return (
|
|
68
|
+
`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` +
|
|
69
|
+
`${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Trạng thái toàn bộ feature so với một app.
|
|
75
|
+
* → [{feature, steps: [{num, slug, materialized}] , schemaTarget, schemaSynced}]
|
|
76
|
+
*/
|
|
77
|
+
export function featureStatus({ featuresRoot, appRoot }) {
|
|
78
|
+
const migrationsDir = join(appRoot, "prisma", "migrations");
|
|
79
|
+
return listFeatures(featuresRoot).map((feature) => {
|
|
80
|
+
const featureDir = join(featuresRoot, feature);
|
|
81
|
+
const schemaSource = join(featureDir, "schema.prisma");
|
|
82
|
+
const schemaTarget = join(appRoot, "prisma", "schema", `goerp-${feature}.prisma`);
|
|
83
|
+
const schemaSynced =
|
|
84
|
+
existsSync(schemaSource) &&
|
|
85
|
+
existsSync(schemaTarget) &&
|
|
86
|
+
readFileSync(schemaTarget, "utf8").includes(readFileSync(schemaSource, "utf8").trim());
|
|
87
|
+
return {
|
|
88
|
+
feature,
|
|
89
|
+
hasSchema: existsSync(schemaSource),
|
|
90
|
+
schemaTarget,
|
|
91
|
+
schemaSynced,
|
|
92
|
+
steps: listFeatureSteps(featureDir).map((step) => ({
|
|
93
|
+
...step,
|
|
94
|
+
materialized: isStepMaterialized(migrationsDir, feature, step),
|
|
95
|
+
})),
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const GENERATED_HEADER = (feature) =>
|
|
101
|
+
`// GENERATED từ @goerp/core (features/${feature}/schema.prisma) — ĐỪNG sửa tay.\n` +
|
|
102
|
+
`// Nâng cấp core xong chạy: pnpm goerp-features sync\n\n`;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Đồng bộ 1 app: copy schema fragments + materialize các bước SQL còn thiếu.
|
|
106
|
+
* Trả về log hành động (để CLI in + test assert). KHÔNG chạy prisma — app tự
|
|
107
|
+
* `prisma migrate deploy` (giữ nguyên flow deploy hiện có).
|
|
108
|
+
*
|
|
109
|
+
* @param {{ featuresRoot: string, appRoot: string, now?: Date, only?: string[] }} options
|
|
110
|
+
*/
|
|
111
|
+
export function syncFeatures({ featuresRoot, appRoot, now = new Date(), only = [] }) {
|
|
112
|
+
const actions = [];
|
|
113
|
+
const migrationsDir = join(appRoot, "prisma", "migrations");
|
|
114
|
+
let offset = 0;
|
|
115
|
+
|
|
116
|
+
for (const feature of listFeatures(featuresRoot)) {
|
|
117
|
+
if (only.length > 0 && !only.includes(feature)) continue;
|
|
118
|
+
const featureDir = join(featuresRoot, feature);
|
|
119
|
+
|
|
120
|
+
// 1) Schema fragment → prisma/schema/goerp-<feature>.prisma (đè)
|
|
121
|
+
const schemaSource = join(featureDir, "schema.prisma");
|
|
122
|
+
if (existsSync(schemaSource)) {
|
|
123
|
+
const schemaDir = join(appRoot, "prisma", "schema");
|
|
124
|
+
mkdirSync(schemaDir, { recursive: true });
|
|
125
|
+
const target = join(schemaDir, `goerp-${feature}.prisma`);
|
|
126
|
+
const content = GENERATED_HEADER(feature) + readFileSync(schemaSource, "utf8");
|
|
127
|
+
const changed = !existsSync(target) || readFileSync(target, "utf8") !== content;
|
|
128
|
+
if (changed) {
|
|
129
|
+
writeFileSync(target, content);
|
|
130
|
+
actions.push({ type: "schema", feature, target });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 2) Materialize các bước SQL chưa có
|
|
135
|
+
for (const step of listFeatureSteps(featureDir)) {
|
|
136
|
+
if (isStepMaterialized(migrationsDir, feature, step)) continue;
|
|
137
|
+
const dirName = `${migrationTimestamp(now, offset++)}_${stepDirSuffix(feature, step)}`;
|
|
138
|
+
const dir = join(migrationsDir, dirName);
|
|
139
|
+
mkdirSync(dir, { recursive: true });
|
|
140
|
+
cpSync(step.file, join(dir, "migration.sql"));
|
|
141
|
+
actions.push({ type: "migration", feature, step: `${step.num}_${step.slug}`, dir: dirName });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return actions;
|
|
145
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { compileFilterTree } from "../lib/filter-tree";
|
|
4
|
+
|
|
5
|
+
const allowAll = { isAllowed: () => true };
|
|
6
|
+
|
|
7
|
+
describe("compileFilterTree", () => {
|
|
8
|
+
it("leaf đơn → điều kiện Prisma", () => {
|
|
9
|
+
expect(
|
|
10
|
+
compileFilterTree(
|
|
11
|
+
{ field: "status", operator: "eq", value: "paid" },
|
|
12
|
+
allowAll,
|
|
13
|
+
),
|
|
14
|
+
).toEqual({ status: "paid" });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("khoảng gte+lte CÙNG field không đè nhau (điểm yếu của filter phẳng)", () => {
|
|
18
|
+
expect(
|
|
19
|
+
compileFilterTree(
|
|
20
|
+
{
|
|
21
|
+
$and: [
|
|
22
|
+
{ field: "total", operator: "gte", value: 1000 },
|
|
23
|
+
{ field: "total", operator: "lte", value: 5000 },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
allowAll,
|
|
27
|
+
),
|
|
28
|
+
).toEqual({
|
|
29
|
+
AND: [{ total: { gte: 1000 } }, { total: { lte: 5000 } }],
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("$or lồng trong $and + dotted relation path", () => {
|
|
34
|
+
expect(
|
|
35
|
+
compileFilterTree(
|
|
36
|
+
{
|
|
37
|
+
$and: [
|
|
38
|
+
{ field: "branchId", operator: "eq", value: "b1" },
|
|
39
|
+
{
|
|
40
|
+
$or: [
|
|
41
|
+
{ field: "status", operator: "eq", value: "paid" },
|
|
42
|
+
{ field: "customer.name", operator: "contains", value: "an" },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
allowAll,
|
|
48
|
+
),
|
|
49
|
+
).toEqual({
|
|
50
|
+
AND: [
|
|
51
|
+
{ branchId: "b1" },
|
|
52
|
+
{
|
|
53
|
+
OR: [
|
|
54
|
+
{ status: "paid" },
|
|
55
|
+
{ customer: { name: { contains: "an", mode: "insensitive" } } },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("field bị cấm → bỏ leaf + báo onDisallowed, phần còn lại giữ nguyên", () => {
|
|
63
|
+
const disallowed: string[] = [];
|
|
64
|
+
const where = compileFilterTree(
|
|
65
|
+
{
|
|
66
|
+
$and: [
|
|
67
|
+
{ field: "secretCost", operator: "gt", value: 0 },
|
|
68
|
+
{ field: "status", operator: "eq", value: "paid" },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
isAllowed: (f) => f !== "secretCost",
|
|
73
|
+
onDisallowed: (f) => disallowed.push(f),
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
expect(where).toEqual({ status: "paid" });
|
|
77
|
+
expect(disallowed).toEqual(["secretCost"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("value rỗng → bỏ leaf; isNull/isNotNull không cần value", () => {
|
|
81
|
+
expect(
|
|
82
|
+
compileFilterTree({ field: "status", operator: "eq", value: "" }, allowAll),
|
|
83
|
+
).toBeNull();
|
|
84
|
+
expect(
|
|
85
|
+
compileFilterTree({ field: "deletedAt", operator: "isNull" }, allowAll),
|
|
86
|
+
).toEqual({ deletedAt: null });
|
|
87
|
+
expect(
|
|
88
|
+
compileFilterTree({ field: "email", operator: "isNotNull" }, allowAll),
|
|
89
|
+
).toEqual({ email: { not: null } });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("nhóm 1 phần tử được rút gọn, nhóm rỗng → null", () => {
|
|
93
|
+
expect(
|
|
94
|
+
compileFilterTree(
|
|
95
|
+
{ $or: [{ field: "status", operator: "eq", value: "paid" }] },
|
|
96
|
+
allowAll,
|
|
97
|
+
),
|
|
98
|
+
).toEqual({ status: "paid" });
|
|
99
|
+
expect(compileFilterTree({ $and: [] }, allowAll)).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("chặn cây quá sâu và quá nhiều điều kiện", () => {
|
|
103
|
+
let node: Record<string, unknown> = {
|
|
104
|
+
field: "a",
|
|
105
|
+
operator: "eq",
|
|
106
|
+
value: 1,
|
|
107
|
+
};
|
|
108
|
+
for (let i = 0; i < 6; i++) node = { $and: [node] };
|
|
109
|
+
expect(() => compileFilterTree(node, allowAll)).toThrow(/độ sâu/);
|
|
110
|
+
|
|
111
|
+
const leaves = Array.from({ length: 31 }, (_, i) => ({
|
|
112
|
+
field: `f${i}`,
|
|
113
|
+
operator: "eq",
|
|
114
|
+
value: i,
|
|
115
|
+
}));
|
|
116
|
+
expect(() => compileFilterTree({ $and: leaves }, allowAll)).toThrow(/điều kiện/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("cấu trúc sai → throw (route trả 4xx, không âm thầm bỏ lọc)", () => {
|
|
120
|
+
expect(() => compileFilterTree("x", allowAll)).toThrow();
|
|
121
|
+
expect(() => compileFilterTree({ $and: "x" }, allowAll)).toThrow();
|
|
122
|
+
expect(() => compileFilterTree({ operator: "eq" }, allowAll)).toThrow();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter tree DSL → Prisma where.
|
|
3
|
+
*
|
|
4
|
+
* Bổ khuyết cho filter phẳng `ActiveFilter[]` của list(): phẳng chỉ AND ngầm
|
|
5
|
+
* và hai điều kiện cùng field ĐÈ nhau (không diễn đạt được khoảng gte+lte hay
|
|
6
|
+
* nhóm OR). Tree cho phép:
|
|
7
|
+
*
|
|
8
|
+
* { $and: [ { field: "total", operator: "gte", value: 1e6 },
|
|
9
|
+
* { field: "total", operator: "lte", value: 5e6 },
|
|
10
|
+
* { $or: [ { field: "status", operator: "eq", value: "paid" },
|
|
11
|
+
* { field: "customer.name", operator: "contains", value: "an" } ] } ] }
|
|
12
|
+
*
|
|
13
|
+
* An toàn: mọi leaf đi qua CÙNG guard `isAllowed` với filter phẳng (field
|
|
14
|
+
* ngoài config bị bỏ + cảnh báo); chặn sâu (depth) và số leaf để không nhận
|
|
15
|
+
* cây tuỳ ý từ client.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface FilterTreeLeaf {
|
|
19
|
+
field: string;
|
|
20
|
+
operator: string;
|
|
21
|
+
value?: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type FilterTreeNode =
|
|
25
|
+
| { $and: FilterTreeNode[] }
|
|
26
|
+
| { $or: FilterTreeNode[] }
|
|
27
|
+
| FilterTreeLeaf;
|
|
28
|
+
|
|
29
|
+
const MAX_DEPTH = 4;
|
|
30
|
+
const MAX_LEAVES = 30;
|
|
31
|
+
|
|
32
|
+
/** Điều kiện Prisma cho một toán tử — dùng chung với filter phẳng. */
|
|
33
|
+
export function conditionForOperator(op: string, value: unknown): unknown {
|
|
34
|
+
switch (op) {
|
|
35
|
+
case "contains":
|
|
36
|
+
return { contains: value, mode: "insensitive" };
|
|
37
|
+
case "in":
|
|
38
|
+
return { in: value };
|
|
39
|
+
case "notIn":
|
|
40
|
+
return { notIn: value };
|
|
41
|
+
case "eq":
|
|
42
|
+
return value;
|
|
43
|
+
case "ne":
|
|
44
|
+
return { not: value };
|
|
45
|
+
case "gt":
|
|
46
|
+
return { gt: value };
|
|
47
|
+
case "gte":
|
|
48
|
+
return { gte: value };
|
|
49
|
+
case "lt":
|
|
50
|
+
return { lt: value };
|
|
51
|
+
case "lte":
|
|
52
|
+
return { lte: value };
|
|
53
|
+
case "startsWith":
|
|
54
|
+
return { startsWith: value, mode: "insensitive" };
|
|
55
|
+
case "endsWith":
|
|
56
|
+
return { endsWith: value, mode: "insensitive" };
|
|
57
|
+
case "isNull":
|
|
58
|
+
return null;
|
|
59
|
+
case "isNotNull":
|
|
60
|
+
return { not: null };
|
|
61
|
+
default:
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** isNull/isNotNull không cần value — mọi toán tử khác value rỗng là bỏ leaf. */
|
|
67
|
+
const VALUELESS_OPS = new Set(["isNull", "isNotNull"]);
|
|
68
|
+
|
|
69
|
+
export interface CompileFilterTreeOptions {
|
|
70
|
+
/** Cùng guard với filter phẳng: field/relation phải nằm trong entity config. */
|
|
71
|
+
isAllowed: (name: string) => boolean;
|
|
72
|
+
/** Gọi khi một leaf bị bỏ (field cấm) — để log cảnh báo. */
|
|
73
|
+
onDisallowed?: (field: string) => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compile tree → Prisma where. Trả `null` khi cây rỗng/không còn leaf hợp lệ
|
|
78
|
+
* (caller bỏ qua). Throw khi cây sai cấu trúc hoặc vượt giới hạn — route trả
|
|
79
|
+
* 400 cho client sửa, không âm thầm nuốt.
|
|
80
|
+
*/
|
|
81
|
+
export function compileFilterTree(
|
|
82
|
+
node: unknown,
|
|
83
|
+
options: CompileFilterTreeOptions,
|
|
84
|
+
): Record<string, unknown> | null {
|
|
85
|
+
const budget = { leaves: 0 };
|
|
86
|
+
return compileNode(node, options, 0, budget);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function compileNode(
|
|
90
|
+
node: unknown,
|
|
91
|
+
options: CompileFilterTreeOptions,
|
|
92
|
+
depth: number,
|
|
93
|
+
budget: { leaves: number },
|
|
94
|
+
): Record<string, unknown> | null {
|
|
95
|
+
if (node === null || node === undefined) return null;
|
|
96
|
+
if (typeof node !== "object" || Array.isArray(node)) {
|
|
97
|
+
throw new Error("filterTree: node phải là object");
|
|
98
|
+
}
|
|
99
|
+
if (depth > MAX_DEPTH) {
|
|
100
|
+
throw new Error(`filterTree: vượt độ sâu tối đa ${MAX_DEPTH}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const group = node as { $and?: unknown; $or?: unknown };
|
|
104
|
+
if (group.$and !== undefined || group.$or !== undefined) {
|
|
105
|
+
const isAnd = group.$and !== undefined;
|
|
106
|
+
const children = isAnd ? group.$and : group.$or;
|
|
107
|
+
if (!Array.isArray(children)) {
|
|
108
|
+
throw new Error(`filterTree: ${isAnd ? "$and" : "$or"} phải là mảng`);
|
|
109
|
+
}
|
|
110
|
+
const compiled = children
|
|
111
|
+
.map((child) => compileNode(child, options, depth + 1, budget))
|
|
112
|
+
.filter((c): c is Record<string, unknown> => c !== null);
|
|
113
|
+
if (compiled.length === 0) return null;
|
|
114
|
+
if (compiled.length === 1) return compiled[0];
|
|
115
|
+
return isAnd ? { AND: compiled } : { OR: compiled };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Leaf
|
|
119
|
+
const leaf = node as FilterTreeLeaf;
|
|
120
|
+
if (typeof leaf.field !== "string" || typeof leaf.operator !== "string") {
|
|
121
|
+
throw new Error("filterTree: leaf cần { field, operator }");
|
|
122
|
+
}
|
|
123
|
+
if (++budget.leaves > MAX_LEAVES) {
|
|
124
|
+
throw new Error(`filterTree: vượt ${MAX_LEAVES} điều kiện`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const { field, operator, value } = leaf;
|
|
128
|
+
if (
|
|
129
|
+
!VALUELESS_OPS.has(operator) &&
|
|
130
|
+
(value === undefined ||
|
|
131
|
+
value === null ||
|
|
132
|
+
value === "" ||
|
|
133
|
+
(Array.isArray(value) && value.length === 0))
|
|
134
|
+
) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (!options.isAllowed(field)) {
|
|
138
|
+
options.onDisallowed?.(field);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const condition = conditionForOperator(operator, value);
|
|
143
|
+
// Dotted path → lồng theo relation: "customer.name" → { customer: { name: cond } }
|
|
144
|
+
const parts = field.split(".");
|
|
145
|
+
let out: Record<string, unknown> = { [parts.pop()!]: condition };
|
|
146
|
+
while (parts.length) {
|
|
147
|
+
out = { [parts.pop()!]: out };
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// getModelName: (e) => getModelName(e, MODEL_MAP),
|
|
16
16
|
// });
|
|
17
17
|
|
|
18
|
+
import { compileFilterTree } from "./lib/filter-tree";
|
|
18
19
|
import type { CrudQueryParams, CrudResponse, EntityConfig } from "../types";
|
|
19
20
|
import { serializeDecimalFields } from "../utils/serialize";
|
|
20
21
|
|
|
@@ -260,6 +261,19 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
260
261
|
else target[key] = value;
|
|
261
262
|
}
|
|
262
263
|
}
|
|
264
|
+
// Bộ lọc nâng cao dạng cây ($and/$or, nhiều điều kiện cùng field) —
|
|
265
|
+
// cùng guard isAllowed với filter phẳng; AND vào where (search dùng OR
|
|
266
|
+
// nên không đụng nhau). Cây sai cấu trúc → throw (route trả lỗi 4xx).
|
|
267
|
+
if (params.filterTree) {
|
|
268
|
+
const compiled = compileFilterTree(params.filterTree, {
|
|
269
|
+
isAllowed,
|
|
270
|
+
onDisallowed: (field) =>
|
|
271
|
+
log.warn(`Ignoring filterTree condition on disallowed field "${field}" for entity "${entity}"`),
|
|
272
|
+
});
|
|
273
|
+
if (compiled) {
|
|
274
|
+
where.AND = [...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []), compiled];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
263
277
|
|
|
264
278
|
const orderBy: any = {};
|
|
265
279
|
const applySort = (field: string, direction: any) => {
|
package/src/crud/server.ts
CHANGED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { featureStatus, migrationTimestamp, syncFeatures } from "../../../scripts/feature-sync.mjs";
|
|
8
|
+
|
|
9
|
+
let featuresRoot: string;
|
|
10
|
+
let appRoot: string;
|
|
11
|
+
|
|
12
|
+
function writeFeature(name: string, steps: Record<string, string>, schema?: string) {
|
|
13
|
+
const dir = join(featuresRoot, name);
|
|
14
|
+
mkdirSync(join(dir, "migrations"), { recursive: true });
|
|
15
|
+
if (schema) writeFileSync(join(dir, "schema.prisma"), schema);
|
|
16
|
+
for (const [file, sql] of Object.entries(steps)) {
|
|
17
|
+
writeFileSync(join(dir, "migrations", file), sql);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
featuresRoot = mkdtempSync(join(tmpdir(), "goerp-features-"));
|
|
23
|
+
appRoot = mkdtempSync(join(tmpdir(), "goerp-app-"));
|
|
24
|
+
mkdirSync(join(appRoot, "prisma", "migrations"), { recursive: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
rmSync(featuresRoot, { recursive: true, force: true });
|
|
29
|
+
rmSync(appRoot, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("syncFeatures", () => {
|
|
33
|
+
it("materialize schema fragment + bước SQL vào app trắng", () => {
|
|
34
|
+
writeFeature(
|
|
35
|
+
"background-tasks",
|
|
36
|
+
{ "0001_init.sql": "CREATE TABLE IF NOT EXISTS t(id text);" },
|
|
37
|
+
"model BackgroundTask { id String @id }"
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const actions = syncFeatures({ featuresRoot, appRoot, now: new Date("2026-07-31T00:00:00Z") })
|
|
41
|
+
expect(actions.map((a: { type: string }) => a.type)).toEqual(["schema", "migration"])
|
|
42
|
+
|
|
43
|
+
// Schema fragment có header GENERATED
|
|
44
|
+
const schema = readFileSync(
|
|
45
|
+
join(appRoot, "prisma", "schema", "goerp-background-tasks.prisma"),
|
|
46
|
+
"utf8"
|
|
47
|
+
)
|
|
48
|
+
expect(schema).toContain("GENERATED từ @goerp/core")
|
|
49
|
+
expect(schema).toContain("model BackgroundTask")
|
|
50
|
+
|
|
51
|
+
// Migration folder đúng tên <ts>_goerp_<feature>_<step>
|
|
52
|
+
const dirs = readdirSync(join(appRoot, "prisma", "migrations"))
|
|
53
|
+
expect(dirs).toEqual(["20260731000000_goerp_background_tasks_0001_init"])
|
|
54
|
+
expect(
|
|
55
|
+
readFileSync(
|
|
56
|
+
join(appRoot, "prisma", "migrations", dirs[0], "migration.sql"),
|
|
57
|
+
"utf8"
|
|
58
|
+
)
|
|
59
|
+
).toContain("CREATE TABLE IF NOT EXISTS")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("idempotent: sync lần 2 không tạo gì thêm (bất kể timestamp khác)", () => {
|
|
63
|
+
writeFeature("background-tasks", { "0001_init.sql": "SELECT 1;" })
|
|
64
|
+
syncFeatures({ featuresRoot, appRoot, now: new Date("2026-07-31T00:00:00Z") })
|
|
65
|
+
const again = syncFeatures({ featuresRoot, appRoot, now: new Date("2026-08-01T09:09:09Z") })
|
|
66
|
+
expect(again).toEqual([])
|
|
67
|
+
expect(readdirSync(join(appRoot, "prisma", "migrations"))).toHaveLength(1)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("nâng cấp core thêm bước mới → chỉ materialize bước thiếu, giữ thứ tự", () => {
|
|
71
|
+
writeFeature("background-tasks", { "0001_init.sql": "SELECT 1;" })
|
|
72
|
+
syncFeatures({ featuresRoot, appRoot, now: new Date("2026-07-31T00:00:00Z") })
|
|
73
|
+
|
|
74
|
+
writeFileSync(
|
|
75
|
+
join(featuresRoot, "background-tasks", "migrations", "0002_add-col.sql"),
|
|
76
|
+
"ALTER TABLE t ADD COLUMN IF NOT EXISTS x text;"
|
|
77
|
+
)
|
|
78
|
+
const actions = syncFeatures({ featuresRoot, appRoot, now: new Date("2026-08-02T00:00:00Z") })
|
|
79
|
+
expect(actions).toHaveLength(1)
|
|
80
|
+
expect(actions[0].step).toBe("0002_add-col")
|
|
81
|
+
|
|
82
|
+
const dirs = readdirSync(join(appRoot, "prisma", "migrations")).sort()
|
|
83
|
+
expect(dirs[0]).toContain("0001_init")
|
|
84
|
+
expect(dirs[1]).toContain("0002_add_col")
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("nhiều bước trong 1 lần sync giữ thứ tự qua timestamp tăng dần", () => {
|
|
88
|
+
writeFeature("f-a", { "0001_one.sql": "SELECT 1;", "0002_two.sql": "SELECT 2;" })
|
|
89
|
+
syncFeatures({ featuresRoot, appRoot, now: new Date("2026-07-31T00:00:00Z") })
|
|
90
|
+
const dirs = readdirSync(join(appRoot, "prisma", "migrations")).sort()
|
|
91
|
+
expect(dirs[0]).toContain("0001_one")
|
|
92
|
+
expect(dirs[1]).toContain("0002_two")
|
|
93
|
+
expect(dirs[0] < dirs[1]).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("--only giới hạn feature; schema đổi nội dung thì được ghi đè", () => {
|
|
97
|
+
writeFeature("f-a", { "0001_a.sql": "SELECT 1;" }, "model A { id String @id }")
|
|
98
|
+
writeFeature("f-b", { "0001_b.sql": "SELECT 1;" })
|
|
99
|
+
const actions = syncFeatures({ featuresRoot, appRoot, only: ["f-a"] })
|
|
100
|
+
expect(actions.every((a: { feature: string }) => a.feature === "f-a")).toBe(true)
|
|
101
|
+
|
|
102
|
+
// core bump: schema fragment đổi → sync ghi đè bản GENERATED
|
|
103
|
+
writeFileSync(
|
|
104
|
+
join(featuresRoot, "f-a", "schema.prisma"),
|
|
105
|
+
"model A { id String @id\n extra String? }"
|
|
106
|
+
)
|
|
107
|
+
const again = syncFeatures({ featuresRoot, appRoot, only: ["f-a"] })
|
|
108
|
+
expect(again.some((a: { type: string }) => a.type === "schema")).toBe(true)
|
|
109
|
+
expect(
|
|
110
|
+
readFileSync(join(appRoot, "prisma", "schema", "goerp-f-a.prisma"), "utf8")
|
|
111
|
+
).toContain("extra String?")
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe("featureStatus", () => {
|
|
116
|
+
it("báo pending trước sync, đủ sau sync", () => {
|
|
117
|
+
writeFeature("f-a", { "0001_a.sql": "SELECT 1;" }, "model A { id String @id }")
|
|
118
|
+
let status = featureStatus({ featuresRoot, appRoot })
|
|
119
|
+
expect(status[0].steps[0].materialized).toBe(false)
|
|
120
|
+
expect(status[0].schemaSynced).toBe(false)
|
|
121
|
+
|
|
122
|
+
syncFeatures({ featuresRoot, appRoot })
|
|
123
|
+
status = featureStatus({ featuresRoot, appRoot })
|
|
124
|
+
expect(status[0].steps[0].materialized).toBe(true)
|
|
125
|
+
expect(status[0].schemaSynced).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe("migrationTimestamp", () => {
|
|
130
|
+
it("dạng YYYYMMDDHHMMSS UTC + offset giây", () => {
|
|
131
|
+
const now = new Date("2026-07-31T01:02:03Z")
|
|
132
|
+
expect(migrationTimestamp(now)).toBe("20260731010203")
|
|
133
|
+
expect(migrationTimestamp(now, 2)).toBe("20260731010205")
|
|
134
|
+
})
|
|
135
|
+
})
|
package/src/types/index.ts
CHANGED
|
@@ -621,6 +621,8 @@ export interface CrudQueryParams {
|
|
|
621
621
|
search?: string;
|
|
622
622
|
sort?: SortingState;
|
|
623
623
|
filters?: ActiveFilter[];
|
|
624
|
+
/** Bộ lọc nâng cao dạng cây $and/$or — xem crud/lib/filter-tree. */
|
|
625
|
+
filterTree?: import("../crud/lib/filter-tree").FilterTreeNode;
|
|
624
626
|
}
|
|
625
627
|
|
|
626
628
|
export interface CrudResponse<T = unknown> {
|