@goplusvn/core 0.1.58 → 0.1.60
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/PLATFORM.md +83 -0
- package/bin/goerp-init.mjs +141 -0
- package/package.json +4 -4
- package/src/branch-scope/__tests__/branch-scope.test.ts +288 -0
- package/src/branch-scope/context.ts +66 -0
- package/src/branch-scope/guard.ts +100 -0
- package/src/branch-scope/index.ts +42 -0
- package/src/branch-scope/scope.ts +149 -0
- package/src/branch-scope/types.ts +57 -0
- package/src/cron/db-cron-manager.ts +3 -3
- package/src/cron/index.ts +2 -2
- package/src/{infrastructure/cron/cron-manager.ts → cron/simple-cron-job.ts} +1 -1
- package/src/infrastructure/__tests__/architecture-verification.spec.ts +13 -97
- package/src/infrastructure/index.ts +4 -7
- package/src/ui/management/index.ts +3 -2
- package/templates/starter-app/.dockerignore +41 -0
- package/templates/starter-app/.env.example +25 -0
- package/templates/starter-app/AGENTS.md +52 -0
- package/templates/starter-app/Dockerfile +74 -0
- package/templates/starter-app/README.md +141 -0
- package/templates/starter-app/gitignore +9 -0
- package/templates/starter-app/next.config.mjs +50 -0
- package/templates/starter-app/package.json +55 -0
- package/templates/starter-app/postcss.config.mjs +5 -0
- package/templates/starter-app/prisma/migrations/20260801000000_init/migration.sql +504 -0
- package/templates/starter-app/prisma/migrations/20260801091814_goerp_audit_logs_0001_init/migration.sql +33 -0
- package/templates/starter-app/prisma/migrations/20260801091815_goerp_background_tasks_0001_init/migration.sql +23 -0
- package/templates/starter-app/prisma/migrations/20260801091816_goerp_error_logs_0001_init/migration.sql +32 -0
- package/templates/starter-app/prisma/migrations/20260801091817_goerp_notifications_0001_init/migration.sql +59 -0
- package/templates/starter-app/prisma/migrations/20260801091818_goerp_system_jobs_0001_init/migration.sql +47 -0
- package/templates/starter-app/prisma/schema/auth.prisma +87 -0
- package/templates/starter-app/prisma/schema/domain.prisma +24 -0
- package/templates/starter-app/prisma/schema/goerp-audit-logs.prisma +23 -0
- package/templates/starter-app/prisma/schema/goerp-background-tasks.prisma +25 -0
- package/templates/starter-app/prisma/schema/goerp-error-logs.prisma +31 -0
- package/templates/starter-app/prisma/schema/goerp-notifications.prisma +49 -0
- package/templates/starter-app/prisma/schema/goerp-system-jobs.prisma +45 -0
- package/templates/starter-app/prisma/schema/organization.prisma +31 -0
- package/templates/starter-app/prisma/schema/rbac.prisma +100 -0
- package/templates/starter-app/prisma/schema/schema.prisma +8 -0
- package/templates/starter-app/prisma/schema/system.prisma +22 -0
- package/templates/starter-app/prisma/seed.ts +127 -0
- package/templates/starter-app/prisma.config.ts +20 -0
- package/templates/starter-app/public/.gitkeep +2 -0
- package/templates/starter-app/scripts/rbac-sync.ts +235 -0
- package/templates/starter-app/src/__tests__/architecture.test.ts +151 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/audit/page.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/error-logs/page.tsx +21 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/jobs/page.tsx +18 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/settings/page.tsx +22 -0
- package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +41 -0
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +13 -0
- package/templates/starter-app/src/app/[lang]/(main)/notifications/page.tsx +13 -0
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +130 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +60 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +41 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +48 -0
- package/templates/starter-app/src/app/[lang]/(main)/tasks/page.tsx +13 -0
- package/templates/starter-app/src/app/[lang]/(plain)/layout.tsx +10 -0
- package/templates/starter-app/src/app/[lang]/(plain)/sign-in/page.tsx +39 -0
- package/templates/starter-app/src/app/[lang]/layout.tsx +21 -0
- package/templates/starter-app/src/app/api/admin/system/audit/route.ts +60 -0
- package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +39 -0
- package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +96 -0
- package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +17 -0
- package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +19 -0
- package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +20 -0
- package/templates/starter-app/src/app/api/admin/system/settings/route.ts +49 -0
- package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +28 -0
- package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +24 -0
- package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +23 -0
- package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +13 -0
- package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +13 -0
- package/templates/starter-app/src/app/api/crud/[entity]/route.ts +14 -0
- package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +23 -0
- package/templates/starter-app/src/app/api/error-logs/route.ts +124 -0
- package/templates/starter-app/src/app/api/files/[...key]/route.ts +25 -0
- package/templates/starter-app/src/app/api/notifications/read/route.ts +29 -0
- package/templates/starter-app/src/app/api/notifications/route.ts +26 -0
- package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +14 -0
- package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +11 -0
- package/templates/starter-app/src/app/api/roles/[id]/route.ts +14 -0
- package/templates/starter-app/src/app/api/roles/route.ts +18 -0
- package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +52 -0
- package/templates/starter-app/src/app/api/tasks/route.ts +37 -0
- package/templates/starter-app/src/app/api/upload/route.ts +15 -0
- package/templates/starter-app/src/app/globals.css +15 -0
- package/templates/starter-app/src/app/layout.tsx +16 -0
- package/templates/starter-app/src/app/page.tsx +8 -0
- package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +30 -0
- package/templates/starter-app/src/configs/entities/department.config.ts +24 -0
- package/templates/starter-app/src/configs/entities/index.ts +13 -0
- package/templates/starter-app/src/configs/i18n.ts +12 -0
- package/templates/starter-app/src/configs/permissions/index.ts +45 -0
- package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +31 -0
- package/templates/starter-app/src/configs/permissions/system.permissions.ts +131 -0
- package/templates/starter-app/src/configs/permissions/types.ts +63 -0
- package/templates/starter-app/src/configs/tenant.ts +18 -0
- package/templates/starter-app/src/data/dictionary.ts +8 -0
- package/templates/starter-app/src/data/navigations.ts +61 -0
- package/templates/starter-app/src/instrumentation.ts +105 -0
- package/templates/starter-app/src/lib/api-handler.ts +157 -0
- package/templates/starter-app/src/lib/auth-client.ts +57 -0
- package/templates/starter-app/src/lib/auth.ts +62 -0
- package/templates/starter-app/src/lib/better-auth.ts +107 -0
- package/templates/starter-app/src/lib/branch-scope.ts +53 -0
- package/templates/starter-app/src/lib/cron/db-cron-manager.ts +42 -0
- package/templates/starter-app/src/lib/crud/index.ts +13 -0
- package/templates/starter-app/src/lib/errors/app-error.ts +2 -0
- package/templates/starter-app/src/lib/errors/error-handler.ts +5 -0
- package/templates/starter-app/src/lib/errors/log-server-error.ts +15 -0
- package/templates/starter-app/src/lib/errors/server-error.ts +11 -0
- package/templates/starter-app/src/lib/logger.ts +30 -0
- package/templates/starter-app/src/lib/page-guard.ts +35 -0
- package/templates/starter-app/src/lib/prisma.ts +80 -0
- package/templates/starter-app/src/lib/rbac/access.ts +87 -0
- package/templates/starter-app/src/lib/storage.ts +28 -0
- package/templates/starter-app/src/providers/index.tsx +54 -0
- package/templates/starter-app/src/providers/mode-provider.tsx +31 -0
- package/templates/starter-app/src/providers/theme-provider.tsx +21 -0
- package/templates/starter-app/src/proxy.ts +45 -0
- package/templates/starter-app/src/server/services/notification-service.ts +31 -0
- package/templates/starter-app/src/server/services/system-config-service.ts +163 -0
- package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +58 -0
- package/templates/starter-app/src/server/tasks/index.ts +16 -0
- package/templates/starter-app/src/server/tasks/task-runner.ts +40 -0
- package/templates/starter-app/src/types/session.ts +29 -0
- package/templates/starter-app/tsconfig.json +47 -0
- package/templates/starter-app/vitest.config.ts +17 -0
- package/src/infrastructure/cron/index.ts +0 -6
- package/src/infrastructure/event-bus/event-bus.ts +0 -145
- package/src/infrastructure/event-bus/index.ts +0 -2
- package/src/infrastructure/event-bus/types.ts +0 -22
- package/src/infrastructure/lock/decorators.ts +0 -67
- package/src/infrastructure/lock/index.ts +0 -2
- package/src/infrastructure/lock/lock-manager.ts +0 -33
- package/src/plugin/apps-registry.ts +0 -97
- package/src/plugin/index.ts +0 -5
- package/src/plugin/types.ts +0 -41
- package/src/ui/management/audit-log-page.tsx +0 -14
- package/src/ui/management/job-management.tsx +0 -308
- package/src/workflow/activity-timeline.tsx +0 -412
- package/src/workflow/approval-workflow.tsx +0 -31
- package/src/workflow/index.ts +0 -2
- /package/src/{infrastructure/cron → cron}/types.ts +0 -0
package/PLATFORM.md
CHANGED
|
@@ -10,6 +10,24 @@ implementation of every pattern here.
|
|
|
10
10
|
> mechanism, or a CAS update into an app, stop — it belongs in core. Promote it
|
|
11
11
|
> and re-export a thin shim from the app so call sites don't change.
|
|
12
12
|
|
|
13
|
+
## Starting a new app
|
|
14
|
+
|
|
15
|
+
This package ships the starter app it documents, plus the scaffolder:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx -p @goplusvn/core goerp-init my-app --port 3010
|
|
19
|
+
cd my-app && pnpm install
|
|
20
|
+
# edit DATABASE_URL in the generated .env
|
|
21
|
+
pnpm prisma:deploy && pnpm seed && pnpm rbac-sync && pnpm dev
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`goerp-init` fills in the app name, port, branding (`src/configs/tenant.ts`) and
|
|
25
|
+
a random `BETTER_AUTH_SECRET`; it never touches a database. `seed` must run
|
|
26
|
+
**before** `rbac-sync` — see the generated `README.md` for why. What you get is
|
|
27
|
+
a working login, RBAC, CRUD, audit/error logs, notifications, background tasks
|
|
28
|
+
and cron — all from core, not copied code. Do not copy the template directory by
|
|
29
|
+
hand: the rename steps are the whole point of the command.
|
|
30
|
+
|
|
13
31
|
## Design system (styles)
|
|
14
32
|
|
|
15
33
|
One import gives the whole token system; override only your brand.
|
|
@@ -255,6 +273,71 @@ until `clearCache()`. The public endpoint is used for presigning only —
|
|
|
255
273
|
a presigned signature is bound to the host that signed it, so signing with the
|
|
256
274
|
internal endpoint yields URLs the browser cannot use.
|
|
257
275
|
|
|
276
|
+
## Branch scope
|
|
277
|
+
|
|
278
|
+
Multi-branch data visibility (`@goerp/core/branch-scope`). Two layers, on
|
|
279
|
+
purpose — the second exists because the first is something a new route can
|
|
280
|
+
simply forget.
|
|
281
|
+
|
|
282
|
+
**Layer 1 — explicit filtering.** Every page/route/service that lists records
|
|
283
|
+
does `const scope = await getBranchScope(session)` then spreads
|
|
284
|
+
`scopedBranchWhere(scope)` into its `where`. `scopedBranchWhere` returns `{}`
|
|
285
|
+
for view-all users, so there is no branching at the call site. Related helpers:
|
|
286
|
+
`canAccessBranch(scope, row.branchId)` for detail pages, `clampBranchFilter`
|
|
287
|
+
for a branch filter the client sent (out-of-scope selections collapse to the
|
|
288
|
+
sentinel — 0 rows, never "no filter"), `clampIdFilter` for branch-owned things
|
|
289
|
+
like warehouses. Records with `branchId: null` are treated as shared and stay
|
|
290
|
+
visible to everyone.
|
|
291
|
+
|
|
292
|
+
**Layer 2 — the safety net.** `createBranchGuardExtension({ models })` is a
|
|
293
|
+
Prisma extension that ANDs the branch condition onto read operations
|
|
294
|
+
(`findMany`, `findFirst`, `findFirstOrThrow`, `count`, `aggregate`, `groupBy`)
|
|
295
|
+
for the declared models. It only acts inside a request context opened by the
|
|
296
|
+
app's api-handler, so RSC pages, cron, webhooks and scripts see unfiltered data
|
|
297
|
+
and must use layer 1. `findUnique*` is deliberately not guarded (its `where`
|
|
298
|
+
only takes unique fields) — guard detail pages with `canAccessBranch`.
|
|
299
|
+
|
|
300
|
+
Wiring, all in the app's composition root:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
// src/lib/branch-scope.ts — the ONE door; nothing else imports the core module
|
|
304
|
+
configureBranchScope<Session>({
|
|
305
|
+
getUserId: (session) => session.user?.id,
|
|
306
|
+
canViewAll: (session) => session.user.roles.includes("admin"),
|
|
307
|
+
getAllowedBranchIds: (session) => session.user.branches, // or pass `db` instead
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
// src/lib/prisma.ts
|
|
311
|
+
db = rawClient.$extends(createBranchGuardExtension({ models: ["Invoice"] }))
|
|
312
|
+
|
|
313
|
+
// src/lib/api-handler.ts (middleware) — resolve is lazy, so a request that
|
|
314
|
+
// never touches a guarded model costs nothing
|
|
315
|
+
runWithBranchScope({ resolve: () => getBranchScope(session) }, () => next())
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Either give `configureBranchScope` a `db` (it reads `user_branches`) or
|
|
319
|
+
`getAllowedBranchIds` when the session already carries the list.
|
|
320
|
+
|
|
321
|
+
Three traps, each of which has cost a real incident:
|
|
322
|
+
|
|
323
|
+
- **A guarded model MUST have a `branchId` column**, or you must route it
|
|
324
|
+
through a relation: `buildWhere: (model, scope) => model === "GoodsReceipt" ?
|
|
325
|
+
scopedBranchWhere(scope, "warehouse") : null`. Declare a model without the
|
|
326
|
+
column and Prisma throws a validation error on *every* read a scoped user
|
|
327
|
+
makes — a blank app, not a few missing rows.
|
|
328
|
+
- **Document numbering must escape the guard.** `MAX(number)` filtered by
|
|
329
|
+
branch produces duplicate numbers and a unique violation. Wrap those queries
|
|
330
|
+
in `runWithoutBranchScope(...)`.
|
|
331
|
+
- **"No branches assigned" means zero rows, not everything.** That is what
|
|
332
|
+
`NO_BRANCH_ACCESS` encodes; an empty `in: []` is ambiguous in Prisma and one
|
|
333
|
+
mistake there exposes the whole table.
|
|
334
|
+
|
|
335
|
+
`runWithBranchScope` / `runWithoutBranchScope` pin a returned thenable to the
|
|
336
|
+
context before handing it back, so `runWithoutBranchScope(() => db.doc.aggregate(…))`
|
|
337
|
+
works even though a PrismaPromise does not run until it is awaited — otherwise
|
|
338
|
+
the query would execute wherever the caller happened to await it, i.e. in the
|
|
339
|
+
wrong scope.
|
|
340
|
+
|
|
258
341
|
## Utils
|
|
259
342
|
|
|
260
343
|
`@goerp/core/utils` (formatCurrency, formatDate, cn, …) plus the granular:
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Khởi tạo một app mới từ template đi kèm gói này.
|
|
3
|
+
//
|
|
4
|
+
// npx -p @goplusvn/core goerp-init <ten-app> [--dir <đường-dẫn>] [--port 3010]
|
|
5
|
+
// pnpm init-app <ten-app> ... (từ trong repo goerp-core)
|
|
6
|
+
//
|
|
7
|
+
// Template nằm trong chính gói npm (packages/core/templates/starter-app) nên
|
|
8
|
+
// lệnh này chạy được ở máy trắng, không cần clone goerp-core.
|
|
9
|
+
//
|
|
10
|
+
// App mới là repo ĐỘC LẬP, không phải thành viên workspace của core: nó cài
|
|
11
|
+
// @goerp/core từ npm như mọi app thật, nên lỗi kiểu "chạy được nhờ workspace"
|
|
12
|
+
// không lọt qua được.
|
|
13
|
+
//
|
|
14
|
+
// Script chỉ chép + thay tên/cổng + sinh secret. Không chạy pnpm install,
|
|
15
|
+
// không đụng DB — những việc đó in ra cho người dùng tự chạy.
|
|
16
|
+
|
|
17
|
+
import { randomBytes } from "node:crypto";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
|
+
const templateDir = path.join(packageRoot, "templates", "starter-app");
|
|
24
|
+
|
|
25
|
+
// Thứ sinh ra lúc build/cài: chép sang chỉ tổ làm app mới hỏng theo cách khó hiểu.
|
|
26
|
+
// `.env` cũng bỏ — nó là bí mật của máy đang chép, app mới sinh secret riêng.
|
|
27
|
+
const SKIP = new Set([
|
|
28
|
+
"node_modules",
|
|
29
|
+
".next",
|
|
30
|
+
".turbo",
|
|
31
|
+
".git",
|
|
32
|
+
".env",
|
|
33
|
+
"tsconfig.tsbuildinfo",
|
|
34
|
+
"next-env.d.ts",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
function fail(message) {
|
|
38
|
+
console.error(`✗ ${message}`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Tham số ──────────────────────────────────────────────────────────────────
|
|
43
|
+
const argv = process.argv.slice(2);
|
|
44
|
+
const name = argv.find((a) => !a.startsWith("-"));
|
|
45
|
+
if (!name) fail("Thiếu tên app. Dùng: goerp-init <ten-app> [--dir <path>] [--port 3010]");
|
|
46
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
47
|
+
fail(`Tên "${name}" không hợp lệ — dùng kebab-case (chữ thường, số, gạch ngang).`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const flag = (key, fallback) => {
|
|
51
|
+
const i = argv.indexOf(`--${key}`);
|
|
52
|
+
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const port = Number(flag("port", "3010"));
|
|
56
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) fail(`Cổng không hợp lệ: ${port}`);
|
|
57
|
+
|
|
58
|
+
const targetDir = path.resolve(flag("dir", path.join(process.cwd(), name)));
|
|
59
|
+
|
|
60
|
+
if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) {
|
|
61
|
+
fail(`${targetDir} đã tồn tại và không rỗng — chọn thư mục khác cho chắc.`);
|
|
62
|
+
}
|
|
63
|
+
if (!fs.existsSync(templateDir)) fail(`Không thấy template ở ${templateDir}`);
|
|
64
|
+
|
|
65
|
+
// ── Chép ─────────────────────────────────────────────────────────────────────
|
|
66
|
+
function copyDir(from, to) {
|
|
67
|
+
fs.mkdirSync(to, { recursive: true });
|
|
68
|
+
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
|
69
|
+
if (SKIP.has(entry.name)) continue;
|
|
70
|
+
const src = path.join(from, entry.name);
|
|
71
|
+
const dst = path.join(to, entry.name);
|
|
72
|
+
if (entry.isDirectory()) copyDir(src, dst);
|
|
73
|
+
else fs.copyFileSync(src, dst);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
copyDir(templateDir, targetDir);
|
|
77
|
+
|
|
78
|
+
const read = (p) => fs.readFileSync(path.join(targetDir, p), "utf8");
|
|
79
|
+
const write = (p, content) => fs.writeFileSync(path.join(targetDir, p), content);
|
|
80
|
+
|
|
81
|
+
// npm/pnpm KHÔNG đóng gói file tên `.gitignore` (nuốt hoặc đổi thành
|
|
82
|
+
// `.npmignore` khi cài). Template ship nó dưới tên `gitignore`, trả lại dấu
|
|
83
|
+
// chấm ở đây — thiếu bước này thì `git add .` đầu tiên của app mới commit luôn
|
|
84
|
+
// `.env` vừa sinh (kèm BETTER_AUTH_SECRET) và cả node_modules.
|
|
85
|
+
fs.renameSync(path.join(targetDir, "gitignore"), path.join(targetDir, ".gitignore"));
|
|
86
|
+
|
|
87
|
+
// "quan-ly-kho" → "Quan Ly Kho": nhãn hiển thị mặc định, chủ app sửa lại sau.
|
|
88
|
+
const title = name
|
|
89
|
+
.split("-")
|
|
90
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
91
|
+
.join(" ");
|
|
92
|
+
|
|
93
|
+
// ── package.json: tên + cổng ─────────────────────────────────────────────────
|
|
94
|
+
const pkg = JSON.parse(read("package.json"));
|
|
95
|
+
pkg.name = name;
|
|
96
|
+
pkg.version = "0.1.0";
|
|
97
|
+
for (const key of ["dev", "start"]) {
|
|
98
|
+
if (pkg.scripts?.[key]) pkg.scripts[key] = pkg.scripts[key].replace(/--port \d+/, `--port ${port}`);
|
|
99
|
+
}
|
|
100
|
+
write("package.json", `${JSON.stringify(pkg, null, 2)}\n`);
|
|
101
|
+
|
|
102
|
+
// ── .env: điền sẵn thứ điền được, để trống thứ chỉ người dùng biết ───────────
|
|
103
|
+
// BETTER_AUTH_SECRET sinh ngay tại đây: bỏ trống thì lần đăng nhập đầu tiên đổ
|
|
104
|
+
// lỗi khó đoán, mà nhét placeholder thì có ngày nó lên production.
|
|
105
|
+
const dbName = name.replace(/-/g, "_");
|
|
106
|
+
const env = read(".env.example")
|
|
107
|
+
.replace(/^DATABASE_URL=.*$/m, `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/${dbName}"`)
|
|
108
|
+
.replace(/^BETTER_AUTH_SECRET=.*$/m, `BETTER_AUTH_SECRET="${randomBytes(32).toString("base64")}"`)
|
|
109
|
+
.replace(/^BETTER_AUTH_URL=.*$/m, `BETTER_AUTH_URL="http://localhost:${port}"`);
|
|
110
|
+
write(".env", env);
|
|
111
|
+
|
|
112
|
+
// ── Thương hiệu: một chỗ duy nhất đổi tên hiển thị toàn app ──────────────────
|
|
113
|
+
// Bỏ qua bước này thì app mới chạy lên vẫn đề "GoERP Starter" trên sidebar,
|
|
114
|
+
// trang đăng nhập và tiêu đề tab — người dựng app phải đi tìm mới sửa được.
|
|
115
|
+
const tenantPath = "src/configs/tenant.ts";
|
|
116
|
+
write(
|
|
117
|
+
tenantPath,
|
|
118
|
+
read(tenantPath)
|
|
119
|
+
.replace(/(\bid:\s*)"[^"]*"/, `$1"${name}"`)
|
|
120
|
+
.replace(/(\bname:\s*)"[^"]*"/, `$1"${title}"`)
|
|
121
|
+
.replace(/(\bcompanyName:\s*)"[^"]*"/, `$1"${title}"`),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// ── Tài liệu: điền tên app ───────────────────────────────────────────────────
|
|
125
|
+
write("AGENTS.md", read("AGENTS.md").replace(/<APP-NAME>/g, name));
|
|
126
|
+
write("README.md", read("README.md").replace(/^# GoERP starter app$/m, `# ${title}`));
|
|
127
|
+
|
|
128
|
+
// ── Xong ─────────────────────────────────────────────────────────────────────
|
|
129
|
+
const relative = path.relative(process.cwd(), targetDir);
|
|
130
|
+
// Đường dẫn tương đối chỉ dễ đọc khi app nằm gần; xa quá thì in tuyệt đối.
|
|
131
|
+
const rel = relative && relative.length < targetDir.length ? relative : targetDir;
|
|
132
|
+
console.log(`✓ Đã tạo ${name} tại ${targetDir}\n`);
|
|
133
|
+
console.log("Tiếp theo:\n");
|
|
134
|
+
console.log(` cd ${rel}`);
|
|
135
|
+
console.log(" pnpm install");
|
|
136
|
+
console.log(" # sửa DATABASE_URL trong .env cho trỏ đúng Postgres của bạn");
|
|
137
|
+
console.log(" pnpm prisma:deploy # dựng bảng (migration _init có sẵn)");
|
|
138
|
+
console.log(" pnpm seed # vai trò + tài khoản admin");
|
|
139
|
+
console.log(" pnpm rbac-sync # PHẢI chạy SAU seed, xem README");
|
|
140
|
+
console.log(` pnpm dev # http://localhost:${port}`);
|
|
141
|
+
console.log("\n.env đã có sẵn BETTER_AUTH_SECRET sinh ngẫu nhiên — đừng commit file này.");
|
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.60",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -12,13 +12,15 @@
|
|
|
12
12
|
"types": "./src/index.ts",
|
|
13
13
|
"sideEffects": false,
|
|
14
14
|
"bin": {
|
|
15
|
-
"goerp-features": "./bin/goerp-features.mjs"
|
|
15
|
+
"goerp-features": "./bin/goerp-features.mjs",
|
|
16
|
+
"goerp-init": "./bin/goerp-init.mjs"
|
|
16
17
|
},
|
|
17
18
|
"files": [
|
|
18
19
|
"src",
|
|
19
20
|
"bin",
|
|
20
21
|
"scripts",
|
|
21
22
|
"features",
|
|
23
|
+
"templates",
|
|
22
24
|
"README.md",
|
|
23
25
|
"CHANGELOG.md",
|
|
24
26
|
"PLATFORM.md"
|
|
@@ -88,8 +90,6 @@
|
|
|
88
90
|
"./audit": "./src/audit/index.ts",
|
|
89
91
|
"./rbac/role-service": "./src/rbac/role-service.ts",
|
|
90
92
|
"./rbac/resource-service": "./src/rbac/resource-service.ts",
|
|
91
|
-
"./infrastructure/cron/cron-manager": "./src/infrastructure/cron/cron-manager.ts",
|
|
92
|
-
"./infrastructure/cron/types": "./src/infrastructure/cron/types.ts",
|
|
93
93
|
"./crud/pages/entity-crud-page": "./src/crud/pages/entity-crud-page.tsx",
|
|
94
94
|
"./auth/auth-service": "./src/auth/auth-service.ts",
|
|
95
95
|
"./package.json": "./package.json",
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getBranchScopeContext,
|
|
5
|
+
resolveAmbientBranchScope,
|
|
6
|
+
runWithBranchScope,
|
|
7
|
+
runWithoutBranchScope,
|
|
8
|
+
type BranchScopeContext,
|
|
9
|
+
} from "../context";
|
|
10
|
+
import { createBranchGuardExtension } from "../guard";
|
|
11
|
+
import {
|
|
12
|
+
canAccessBranch,
|
|
13
|
+
clampBranchFilter,
|
|
14
|
+
clampIdFilter,
|
|
15
|
+
configureBranchScope,
|
|
16
|
+
getBranchScope,
|
|
17
|
+
scopedBranchWhere,
|
|
18
|
+
} from "../scope";
|
|
19
|
+
import { NO_BRANCH_ACCESS, type BranchScope } from "../types";
|
|
20
|
+
|
|
21
|
+
const scoped: BranchScope = { canViewAll: false, allowedBranchIds: ["b1", "b2"] };
|
|
22
|
+
const viewAll: BranchScope = { canViewAll: true };
|
|
23
|
+
|
|
24
|
+
interface FakeSession {
|
|
25
|
+
id?: string;
|
|
26
|
+
admin?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function configure(rows: { branchId: string | null }[]) {
|
|
30
|
+
const findMany = vi.fn(async () => rows);
|
|
31
|
+
configureBranchScope<FakeSession>({
|
|
32
|
+
db: { userBranch: { findMany } },
|
|
33
|
+
getUserId: (s) => s.id,
|
|
34
|
+
canViewAll: (s) => Boolean(s.admin),
|
|
35
|
+
});
|
|
36
|
+
return findMany;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("getBranchScope", () => {
|
|
40
|
+
it("người xem-tất không tốn truy vấn user_branches", async () => {
|
|
41
|
+
const findMany = configure([]);
|
|
42
|
+
expect(await getBranchScope({ id: "u1", admin: true })).toEqual({
|
|
43
|
+
canViewAll: true,
|
|
44
|
+
});
|
|
45
|
+
expect(findMany).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("lấy đúng chi nhánh được gán", async () => {
|
|
49
|
+
configure([{ branchId: "b1" }, { branchId: "b2" }]);
|
|
50
|
+
expect(await getBranchScope({ id: "u1" })).toEqual({
|
|
51
|
+
canViewAll: false,
|
|
52
|
+
allowedBranchIds: ["b1", "b2"],
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("chưa gán chi nhánh nào → sentinel = thấy 0 dòng, KHÔNG phải thấy tất", async () => {
|
|
57
|
+
configure([]);
|
|
58
|
+
expect(await getBranchScope({ id: "u1" })).toEqual({
|
|
59
|
+
canViewAll: false,
|
|
60
|
+
allowedBranchIds: [NO_BRANCH_ACCESS],
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("session không có userId → sentinel (không tra DB bằng undefined)", async () => {
|
|
65
|
+
const findMany = configure([{ branchId: "b1" }]);
|
|
66
|
+
expect(await getBranchScope({})).toEqual({
|
|
67
|
+
canViewAll: false,
|
|
68
|
+
allowedBranchIds: [NO_BRANCH_ACCESS],
|
|
69
|
+
});
|
|
70
|
+
expect(findMany).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("scopedBranchWhere", () => {
|
|
75
|
+
it("xem tất → không thêm điều kiện", () => {
|
|
76
|
+
expect(scopedBranchWhere(viewAll)).toEqual({});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("có phạm vi → CN được gán + bản ghi chưa gắn CN (dùng chung)", () => {
|
|
80
|
+
expect(scopedBranchWhere(scoped)).toEqual({
|
|
81
|
+
OR: [{ branchId: { in: ["b1", "b2"] } }, { branchId: null }],
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("scope qua quan hệ cho model không có cột branchId", () => {
|
|
86
|
+
expect(scopedBranchWhere(scoped, "warehouse")).toEqual({
|
|
87
|
+
OR: [
|
|
88
|
+
{ warehouse: { branchId: { in: ["b1", "b2"] } } },
|
|
89
|
+
{ warehouse: { branchId: null } },
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("canAccessBranch", () => {
|
|
96
|
+
it("xem tất → qua hết", () => {
|
|
97
|
+
expect(canAccessBranch(viewAll, "x")).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
it("bản ghi không gắn CN = dùng chung", () => {
|
|
100
|
+
expect(canAccessBranch(scoped, null)).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
it("đúng/sai theo danh sách được gán", () => {
|
|
103
|
+
expect(canAccessBranch(scoped, "b1")).toBe(true);
|
|
104
|
+
expect(canAccessBranch(scoped, "x")).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("kẹp bộ lọc client gửi lên", () => {
|
|
109
|
+
it("clampBranchFilter: giữ nguyên khi xem tất, lọc bỏ CN ngoài phạm vi", () => {
|
|
110
|
+
expect(clampBranchFilter(["x"], viewAll)).toEqual(["x"]);
|
|
111
|
+
expect(clampBranchFilter(["b1", "x"], scoped)).toEqual(["b1"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("clampBranchFilter: toàn bộ ngoài phạm vi → sentinel, không rơi về không-lọc", () => {
|
|
115
|
+
expect(clampBranchFilter(["x", "y"], scoped)).toEqual([NO_BRANCH_ACCESS]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("clampBranchFilter: không lọc gì → undefined (phạm vi đã lo)", () => {
|
|
119
|
+
expect(clampBranchFilter(undefined, scoped)).toBeUndefined();
|
|
120
|
+
expect(clampBranchFilter("", scoped)).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("clampIdFilter: ngoài danh sách cho phép → sentinel", () => {
|
|
124
|
+
expect(clampIdFilter(["w1", "x"], ["w1", "w2"])).toEqual(["w1"]);
|
|
125
|
+
expect(clampIdFilter(["x"], ["w1"])).toEqual([NO_BRANCH_ACCESS]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("context", () => {
|
|
130
|
+
it("ngoài context → undefined (extension bất động cho cron/script)", () => {
|
|
131
|
+
expect(getBranchScopeContext()).toBeUndefined();
|
|
132
|
+
expect(resolveAmbientBranchScope()).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("resolve lười và cache trong 1 request", async () => {
|
|
136
|
+
const resolve = vi.fn(async () => scoped);
|
|
137
|
+
await runWithBranchScope({ resolve }, async () => {
|
|
138
|
+
expect(resolve).not.toHaveBeenCalled();
|
|
139
|
+
expect(await resolveAmbientBranchScope()).toBe(scoped);
|
|
140
|
+
expect(await resolveAmbientBranchScope()).toBe(scoped);
|
|
141
|
+
expect(resolve).toHaveBeenCalledTimes(1);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("context sống qua await bên trong (PrismaPromise phải await TRONG context)", async () => {
|
|
146
|
+
await runWithBranchScope({ resolve: async () => scoped }, async () => {
|
|
147
|
+
await Promise.resolve();
|
|
148
|
+
expect(getBranchScopeContext()).toBeDefined();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("runWithoutBranchScope thoát guard rồi trả lại context", async () => {
|
|
153
|
+
await runWithBranchScope({ resolve: async () => scoped }, async () => {
|
|
154
|
+
await runWithoutBranchScope(async () => {
|
|
155
|
+
await Promise.resolve();
|
|
156
|
+
expect(getBranchScopeContext()).toBeUndefined();
|
|
157
|
+
expect(resolveAmbientBranchScope()).toBeUndefined();
|
|
158
|
+
});
|
|
159
|
+
expect(getBranchScopeContext()).toBeDefined();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* PrismaPromise lười: truy vấn chạy ở lần `.then()` đầu tiên, không phải lúc
|
|
165
|
+
* gọi `db.receipt.count()`. Giả lập đúng như vậy để chốt rằng callback KHÔNG
|
|
166
|
+
* async cũng chạy trong đúng phạm vi.
|
|
167
|
+
*/
|
|
168
|
+
function lazyQuery(): Promise<BranchScopeContext | undefined> {
|
|
169
|
+
let started: Promise<BranchScopeContext | undefined> | undefined;
|
|
170
|
+
const thenable: PromiseLike<BranchScopeContext | undefined> = {
|
|
171
|
+
then(onFulfilled, onRejected) {
|
|
172
|
+
// Ngữ cảnh được chốt tại đây — chỗ đầu tiên ai đó await.
|
|
173
|
+
started ??= Promise.resolve(getBranchScopeContext());
|
|
174
|
+
return started.then(onFulfilled, onRejected);
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
// PrismaPromise cũng khai kiểu Promise nhưng chỉ chạy ở `.then` đầu tiên.
|
|
178
|
+
return thenable as Promise<BranchScopeContext | undefined>;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
it("callback KHÔNG async trả PrismaPromise vẫn chạy TRONG context", async () => {
|
|
182
|
+
const ctx = await runWithBranchScope({ resolve: async () => scoped }, () =>
|
|
183
|
+
lazyQuery(),
|
|
184
|
+
);
|
|
185
|
+
expect(ctx).toBeDefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("runWithoutBranchScope: callback KHÔNG async vẫn thoát guard", async () => {
|
|
189
|
+
const ctx = await runWithBranchScope(
|
|
190
|
+
{ resolve: async () => scoped },
|
|
191
|
+
async () => runWithoutBranchScope(() => lazyQuery()),
|
|
192
|
+
);
|
|
193
|
+
expect(ctx).toBeUndefined();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("giá trị đồng bộ đi qua nguyên vẹn (không bị bọc thành promise)", () => {
|
|
197
|
+
expect(runWithoutBranchScope(() => 42)).toBe(42);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("createBranchGuardExtension", () => {
|
|
202
|
+
const ext = createBranchGuardExtension({
|
|
203
|
+
models: ["Receipt", "GoodsReceipt"],
|
|
204
|
+
buildWhere: (model, scope) =>
|
|
205
|
+
model === "GoodsReceipt" ? scopedBranchWhere(scope, "warehouse") : null,
|
|
206
|
+
});
|
|
207
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
208
|
+
const run = ext.query.$allModels.$allOperations as (a: any) => Promise<any>;
|
|
209
|
+
|
|
210
|
+
let seen: unknown;
|
|
211
|
+
const query = vi.fn(async (args: unknown) => {
|
|
212
|
+
seen = args;
|
|
213
|
+
return "ok";
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
beforeEach(() => {
|
|
217
|
+
seen = undefined;
|
|
218
|
+
query.mockClear();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const call = (model: string, operation: string, args: unknown) =>
|
|
222
|
+
run({ model, operation, args, query });
|
|
223
|
+
|
|
224
|
+
it("model ngoài danh sách → đi thẳng", async () => {
|
|
225
|
+
await runWithBranchScope({ resolve: async () => scoped }, () =>
|
|
226
|
+
call("Customer", "findMany", { where: { name: "a" } }),
|
|
227
|
+
);
|
|
228
|
+
expect(seen).toEqual({ where: { name: "a" } });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("thao tác ghi và findUnique → không đụng vào", async () => {
|
|
232
|
+
await runWithBranchScope({ resolve: async () => scoped }, async () => {
|
|
233
|
+
await call("Receipt", "findUnique", { where: { id: "r1" } });
|
|
234
|
+
expect(seen).toEqual({ where: { id: "r1" } });
|
|
235
|
+
await call("Receipt", "update", { where: { id: "r1" }, data: {} });
|
|
236
|
+
expect(seen).toEqual({ where: { id: "r1" }, data: {} });
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("ngoài context (cron/script) → không cắt dữ liệu", async () => {
|
|
241
|
+
await call("Receipt", "findMany", { where: { a: 1 } });
|
|
242
|
+
expect(seen).toEqual({ where: { a: 1 } });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("xem tất → không thêm điều kiện", async () => {
|
|
246
|
+
await runWithBranchScope({ resolve: async () => viewAll }, () =>
|
|
247
|
+
call("Receipt", "findMany", { where: { a: 1 } }),
|
|
248
|
+
);
|
|
249
|
+
expect(seen).toEqual({ where: { a: 1 } });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("AND thêm điều kiện chi nhánh, GIỮ nguyên where cũ", async () => {
|
|
253
|
+
await runWithBranchScope({ resolve: async () => scoped }, () =>
|
|
254
|
+
call("Receipt", "findMany", { where: { status: "paid" } }),
|
|
255
|
+
);
|
|
256
|
+
expect(seen).toEqual({
|
|
257
|
+
where: {
|
|
258
|
+
AND: [
|
|
259
|
+
{ status: "paid" },
|
|
260
|
+
{ OR: [{ branchId: { in: ["b1", "b2"] } }, { branchId: null }] },
|
|
261
|
+
],
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("không có where sẵn → guard trở thành where", async () => {
|
|
267
|
+
await runWithBranchScope({ resolve: async () => scoped }, () =>
|
|
268
|
+
call("Receipt", "count", {}),
|
|
269
|
+
);
|
|
270
|
+
expect(seen).toEqual({
|
|
271
|
+
where: { OR: [{ branchId: { in: ["b1", "b2"] } }, { branchId: null }] },
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("buildWhere riêng cho model không có cột branchId (chèn cột không tồn tại = Prisma ném lỗi cho MỌI truy vấn)", async () => {
|
|
276
|
+
await runWithBranchScope({ resolve: async () => scoped }, () =>
|
|
277
|
+
call("GoodsReceipt", "findMany", {}),
|
|
278
|
+
);
|
|
279
|
+
expect(seen).toEqual({
|
|
280
|
+
where: {
|
|
281
|
+
OR: [
|
|
282
|
+
{ warehouse: { branchId: { in: ["b1", "b2"] } } },
|
|
283
|
+
{ warehouse: { branchId: null } },
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
import type { BranchScope } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Context của lưới an toàn lớp 2. `apiHandler` mở context cho MỌI request đã
|
|
7
|
+
* xác thực; extension branch-guard đọc ở đây.
|
|
8
|
+
*
|
|
9
|
+
* Phạm vi được resolve LƯỜI — lần đầu một model bị guard thực sự được đọc.
|
|
10
|
+
* Request không đụng model nào bị guard thì không tốn thêm truy vấn nào.
|
|
11
|
+
*
|
|
12
|
+
* File này cố ý không import phần scope: composition root của app tiêm hàm
|
|
13
|
+
* `resolve` vào lúc mở context, nhờ vậy context không kéo theo prisma và không
|
|
14
|
+
* tạo vòng import.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface BranchScopeContext {
|
|
18
|
+
resolve: () => Promise<BranchScope>;
|
|
19
|
+
/** Cache trong phạm vi 1 request — extension gán ở lần resolve đầu. */
|
|
20
|
+
cached?: Promise<BranchScope>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const store = new AsyncLocalStorage<BranchScopeContext>();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* PrismaPromise là LƯỜI: truy vấn chỉ thật sự chạy ở lần `.then()` đầu tiên.
|
|
27
|
+
* `run(ctx, () => db.receipt.count())` trả promise chưa chạy ra ngoài, người gọi
|
|
28
|
+
* `await` bên ngoài → truy vấn chạy NGOÀI context → guard im lặng không lọc
|
|
29
|
+
* (và với `exit` thì ngược lại: truy vấn cần toàn cục lại bị lọc → trùng số
|
|
30
|
+
* phiếu). Gọi `.then` ngay tại đây, khi còn ở trong/ngoài context đúng như ý,
|
|
31
|
+
* để việc chạy được ghim vào đúng phạm vi — bất kể người gọi await ở đâu.
|
|
32
|
+
*/
|
|
33
|
+
function pinToCurrentContext<T>(result: T): T {
|
|
34
|
+
const thenable = result as { then?: unknown };
|
|
35
|
+
if (typeof thenable?.then !== "function") return result;
|
|
36
|
+
return (result as unknown as Promise<unknown>).then((value) => value) as T;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function runWithBranchScope<T>(
|
|
40
|
+
context: BranchScopeContext,
|
|
41
|
+
fn: () => T | Promise<T>,
|
|
42
|
+
): T | Promise<T> {
|
|
43
|
+
return store.run(context, () => pinToCurrentContext(fn()));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getBranchScopeContext(): BranchScopeContext | undefined {
|
|
47
|
+
return store.getStore();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Phạm vi của request hiện tại (resolve + cache); undefined khi ở ngoài context. */
|
|
51
|
+
export function resolveAmbientBranchScope(): Promise<BranchScope> | undefined {
|
|
52
|
+
const ctx = store.getStore();
|
|
53
|
+
if (!ctx) return undefined;
|
|
54
|
+
ctx.cached ??= ctx.resolve();
|
|
55
|
+
return ctx.cached;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Chạy `fn` NGOÀI lưới guard — cho truy vấn hạ tầng mà kết quả phải toàn cục
|
|
60
|
+
* bất kể phạm vi user. Ca kinh điển: đánh số phiếu (`MAX(number)` bị lọc theo
|
|
61
|
+
* chi nhánh là sinh trùng số → lỗi unique). Mọi `await` bên trong đều thoát
|
|
62
|
+
* guard, nên chỉ bọc đúng truy vấn cần toàn cục, đừng bọc cả handler.
|
|
63
|
+
*/
|
|
64
|
+
export function runWithoutBranchScope<T>(fn: () => T): T {
|
|
65
|
+
return store.exit(() => pinToCurrentContext(fn()));
|
|
66
|
+
}
|