@supacloud/db 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/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # @supacloud/db
2
+
3
+ SupaCloud 的数据库治理层:把 RLS 策略、RPC 函数、触发器、授权(grant)作为**一等资源**做声明式管理,并与 PostgreSQL 真实 Catalog 对账。
4
+
5
+ 定位:它是 Drizzle(schema/迁移)之上的治理层 —— Drizzle 负责表结构,本包负责表结构之外的安全与业务对象(策略、函数、权限)的声明、静态检查与漂移检测。**driver 无关**:所有 Catalog 读取都通过注入的 `QueryExecutor` 完成,不依赖任何数据库客户端,也不 import drizzle-orm(仅类型层兼容 drizzle Table 的内部形状)。
6
+
7
+ ## 目录约定示例
8
+
9
+ ```
10
+ db/
11
+ policies/cases_select.sql -- create policy ... + enable row level security
12
+ functions/case_create.sql -- security definer set search_path = public
13
+ triggers/cases_updated_at.sql
14
+ grants/cases.sql
15
+ tests/cases_select.sql -- pgTAP 或 SQL 冒烟测试
16
+ ```
17
+
18
+ ```ts
19
+ import { defineDatabaseModule } from '@supacloud/db';
20
+ import { cases } from './schema'; // drizzle 表对象
21
+
22
+ export const casesModule = defineDatabaseModule({
23
+ name: 'cases',
24
+ tables: [cases], // 也接受 'public.cases' 字符串
25
+ policies: [
26
+ {
27
+ name: 'cases_select',
28
+ table: 'public.cases',
29
+ operation: 'select',
30
+ roles: ['authenticated'],
31
+ source: 'db/policies/cases_select.sql',
32
+ tests: ['db/tests/cases_select.sql'],
33
+ },
34
+ ],
35
+ functions: [
36
+ {
37
+ name: 'public.case_create',
38
+ source: 'db/functions/case_create.sql',
39
+ security: 'definer',
40
+ permission: 'case.create',
41
+ tests: ['db/tests/case_create.sql'],
42
+ },
43
+ ],
44
+ grants: [
45
+ { object: 'public.cases', privilege: 'SELECT', role: 'authenticated', source: 'db/grants/cases.sql' },
46
+ ],
47
+ });
48
+ ```
49
+
50
+ ## API
51
+
52
+ | 导出 | 说明 |
53
+ | --- | --- |
54
+ | `defineDatabaseModule(options)` | 声明一个数据库模块,归一化表名为 `schema.name` |
55
+ | `readCatalog(executor, schemas?)` | 通过注入的查询执行器读取 PostgreSQL Catalog(默认 `['public']`,参数化 `$1`) |
56
+ | `reconcileModule(module, catalog)` | Manifest 与 Catalog 对账,产出 `ReconcileReport` |
57
+ | `lintSql(sql, file)` / `lintModule(module, readFile)` | 纯 SQL 文本静态分析,无需数据库 |
58
+ | `buildDatabaseManifest(modules)` | 汇总模块为可 JSON 序列化的 `DatabaseManifest`(version 1) |
59
+ | `explainObject(manifest, name)` | 人类可读地解释对象的所属模块、类型、源文件、权限、测试 |
60
+
61
+ ## 诊断码
62
+
63
+ ### 对账(reconcile)
64
+
65
+ | code | 级别 | 含义 |
66
+ | --- | --- | --- |
67
+ | `missing-policy` | error | 声明的策略在 catalog 中不存在 |
68
+ | `missing-function` | error | 声明的 RPC 函数在 catalog 中不存在 |
69
+ | `undeclared-policy` | warn | 归属表上存在 manifest 未声明的策略(漂移) |
70
+ | `rls-disabled` | error | 归属表 `relrowsecurity = false` |
71
+ | `definer-without-search-path` | error | security definer 函数未设置固定 search_path(含空元素或 `pg_temp` 也算不固定) |
72
+ | `security-mismatch` | warn | 声明的 invoker/definer 与 catalog 实际不一致 |
73
+ | `wildcard-grant` | error | 归属表上存在授予 `PUBLIC` 的权限 |
74
+ | `grant-drift` | warn | 声明的授权在 catalog 中不存在 |
75
+
76
+ `ReconcileReport.ok = true` 当且仅当没有 error 级问题。
77
+
78
+ ### Lint(静态分析,正则级、大小写不敏感)
79
+
80
+ | code | 级别 | 含义 |
81
+ | --- | --- | --- |
82
+ | `definer-no-search-path` | error | 源文件含 `security definer` 但不含 `set search_path` |
83
+ | `grant-to-public` | error | `grant ... to public` |
84
+ | `missing-rls-enable` | warn | 声明了策略但所有策略源文件都没有 `enable row level security` |
85
+ | `drop-without-if-exists` | warn | `drop table/column` 缺少 `if exists` |
86
+ | `policy-without-test` | warn | 声明的策略/函数没有 `tests` 条目 |
87
+
88
+ ## 与 Supabase / PostgreSQL 的关系
89
+
90
+ SupaCloud 兼容 Supabase 的托管 PostgreSQL 模型:`authenticated` / `anon` / `service_role` 角色、RLS 策略、`security definer` RPC 都是治理对象。本包读取的系统目录(`pg_class` / `pg_policy` / `pg_proc` / `information_schema`)是标准 PostgreSQL 接口,因此同样适用于自托管 PostgreSQL;针对 Supabase 风格的角色与 schema 约定没有硬编码依赖。
91
+
92
+ ## 开发
93
+
94
+ ```sh
95
+ bun install
96
+ bun test # bun test,同置 *.test.ts
97
+ bun run typecheck
98
+ bun run typecheck:test
99
+ bun run build # bun build --target node + tsc 声明文件
100
+ ```
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Catalog 读取:通过注入的 QueryExecutor 从 PostgreSQL 系统目录读取真实状态。
3
+ * 所有 SQL 参数化($1 = schemas 数组),默认只读 public schema。
4
+ */
5
+ import type { PolicyOperation } from './module.js';
6
+ export interface QueryExecutor {
7
+ query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
8
+ }
9
+ export interface CatalogTable {
10
+ schema: string;
11
+ name: string;
12
+ rlsEnabled: boolean;
13
+ rlsForced: boolean;
14
+ }
15
+ export interface CatalogPolicy {
16
+ schema: string;
17
+ table: string;
18
+ name: string;
19
+ command: PolicyOperation;
20
+ roles: string[];
21
+ usingExpr?: string;
22
+ checkExpr?: string;
23
+ }
24
+ export interface CatalogFunction {
25
+ schema: string;
26
+ name: string;
27
+ security: 'invoker' | 'definer';
28
+ searchPath: string | null;
29
+ language: string;
30
+ }
31
+ export interface CatalogGrant {
32
+ objectSchema: string;
33
+ objectName: string;
34
+ privilege: string;
35
+ grantee: string;
36
+ }
37
+ export interface DatabaseCatalog {
38
+ tables: CatalogTable[];
39
+ policies: CatalogPolicy[];
40
+ functions: CatalogFunction[];
41
+ grants: CatalogGrant[];
42
+ }
43
+ /** 从 proconfig(text[])中提取 search_path=... 配置,无则 null */
44
+ export declare function extractSearchPath(config: string[] | null): string | null;
45
+ export declare function readCatalog(executor: QueryExecutor, schemas?: string[]): Promise<DatabaseCatalog>;
@@ -0,0 +1,5 @@
1
+ export { defineDatabaseModule, type DatabaseModule, type DatabaseModuleOptions, type DrizzleTableLike, type FunctionDecl, type GrantDecl, type PolicyDecl, type PolicyOperation, type TableRef, type TriggerDecl, } from './module.js';
2
+ export { extractSearchPath, readCatalog, type CatalogFunction, type CatalogGrant, type CatalogPolicy, type CatalogTable, type DatabaseCatalog, type QueryExecutor, } from './catalog.js';
3
+ export { reconcileModule, type ReconcileIssue, type ReconcileReport, } from './reconcile.js';
4
+ export { lintModule, lintSql, type LintIssue } from './lint.js';
5
+ export { buildDatabaseManifest, explainObject, type DatabaseManifest, type DatabaseManifestModule, } from './manifest.js';
package/dist/index.js ADDED
@@ -0,0 +1,412 @@
1
+ // src/module.ts
2
+ function normalizeTable(ref) {
3
+ if (typeof ref === "string")
4
+ return ref;
5
+ const schema = ref._.schema ?? "public";
6
+ return `${schema}.${ref._.name}`;
7
+ }
8
+ function defineDatabaseModule(options) {
9
+ return {
10
+ name: options.name,
11
+ tables: (options.tables ?? []).map(normalizeTable),
12
+ policies: options.policies ?? [],
13
+ functions: options.functions ?? [],
14
+ triggers: options.triggers ?? [],
15
+ grants: options.grants ?? []
16
+ };
17
+ }
18
+ // src/catalog.ts
19
+ var TABLES_SQL = `
20
+ SELECT n.nspname AS schema,
21
+ c.relname AS name,
22
+ c.relrowsecurity AS rls_enabled,
23
+ c.relforcerowsecurity AS rls_forced
24
+ FROM pg_class c
25
+ JOIN pg_namespace n ON n.oid = c.relnamespace
26
+ WHERE c.relkind = 'r'
27
+ AND n.nspname = ANY($1)
28
+ ORDER BY n.nspname, c.relname
29
+ `;
30
+ var POLICIES_SQL = `
31
+ SELECT n.nspname AS schema,
32
+ c.relname AS table,
33
+ p.polname AS name,
34
+ p.polcmd AS command,
35
+ ARRAY(
36
+ SELECT CASE WHEN r = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(r) END
37
+ FROM unnest(p.polroles) AS r
38
+ ) AS roles,
39
+ pg_get_expr(p.polqual, p.polrelid) AS using_expr,
40
+ pg_get_expr(p.polwithcheck, p.polrelid) AS check_expr
41
+ FROM pg_policy p
42
+ JOIN pg_class c ON c.oid = p.polrelid
43
+ JOIN pg_namespace n ON n.oid = c.relnamespace
44
+ WHERE n.nspname = ANY($1)
45
+ ORDER BY n.nspname, c.relname, p.polname
46
+ `;
47
+ var FUNCTIONS_SQL = `
48
+ SELECT n.nspname AS schema,
49
+ p.proname AS name,
50
+ p.prosecdef AS security_definer,
51
+ p.proconfig AS config,
52
+ l.lanname AS language
53
+ FROM pg_proc p
54
+ JOIN pg_namespace n ON n.oid = p.pronamespace
55
+ JOIN pg_language l ON l.oid = p.prolang
56
+ WHERE n.nspname = ANY($1)
57
+ ORDER BY n.nspname, p.proname
58
+ `;
59
+ var GRANTS_SQL = `
60
+ SELECT table_schema AS object_schema,
61
+ table_name AS object_name,
62
+ privilege_type AS privilege,
63
+ grantee
64
+ FROM information_schema.role_table_grants
65
+ WHERE table_schema = ANY($1)
66
+ UNION ALL
67
+ SELECT routine_schema AS object_schema,
68
+ routine_name AS object_name,
69
+ privilege_type AS privilege,
70
+ grantee
71
+ FROM information_schema.routine_privileges
72
+ WHERE routine_schema = ANY($1)
73
+ `;
74
+ var POLCMD_MAP = {
75
+ r: "select",
76
+ a: "insert",
77
+ w: "update",
78
+ d: "delete",
79
+ "*": "all"
80
+ };
81
+ function extractSearchPath(config) {
82
+ if (!config)
83
+ return null;
84
+ const entry = config.find((item) => item.startsWith("search_path="));
85
+ if (!entry)
86
+ return null;
87
+ return entry.slice("search_path=".length);
88
+ }
89
+ async function readCatalog(executor, schemas = ["public"]) {
90
+ const params = [schemas];
91
+ const [tableRows, policyRows, functionRows, grantRows] = await Promise.all([
92
+ executor.query(TABLES_SQL, params),
93
+ executor.query(POLICIES_SQL, params),
94
+ executor.query(FUNCTIONS_SQL, params),
95
+ executor.query(GRANTS_SQL, params)
96
+ ]);
97
+ return {
98
+ tables: tableRows.map((row) => ({
99
+ schema: row.schema,
100
+ name: row.name,
101
+ rlsEnabled: row.rls_enabled,
102
+ rlsForced: row.rls_forced
103
+ })),
104
+ policies: policyRows.map((row) => ({
105
+ schema: row.schema,
106
+ table: row.table,
107
+ name: row.name,
108
+ command: POLCMD_MAP[row.command] ?? "all",
109
+ roles: row.roles ?? [],
110
+ usingExpr: row.using_expr ?? undefined,
111
+ checkExpr: row.check_expr ?? undefined
112
+ })),
113
+ functions: functionRows.map((row) => ({
114
+ schema: row.schema,
115
+ name: row.name,
116
+ security: row.security_definer ? "definer" : "invoker",
117
+ searchPath: extractSearchPath(row.config),
118
+ language: row.language
119
+ })),
120
+ grants: grantRows.map((row) => ({
121
+ objectSchema: row.object_schema,
122
+ objectName: row.object_name,
123
+ privilege: row.privilege,
124
+ grantee: row.grantee
125
+ }))
126
+ };
127
+ }
128
+ // src/reconcile.ts
129
+ function splitQualifiedName(name) {
130
+ const dot = name.indexOf(".");
131
+ if (dot === -1)
132
+ return ["public", name];
133
+ return [name.slice(0, dot), name.slice(dot + 1)];
134
+ }
135
+ function isFixedSearchPath(searchPath) {
136
+ if (searchPath === null)
137
+ return false;
138
+ const parts = searchPath.split(",").map((part) => part.trim().replace(/^"|"$/g, ""));
139
+ return parts.every((part) => part !== "" && part.toLowerCase() !== "pg_temp");
140
+ }
141
+ function reconcileModule(module, catalog) {
142
+ const issues = [];
143
+ const ownedTables = new Set(module.tables);
144
+ const push = (severity, code, object, message) => issues.push({ severity, code, object, message });
145
+ for (const policy of module.policies) {
146
+ const [schema, table] = splitQualifiedName(policy.table);
147
+ const found = catalog.policies.some((cp) => cp.schema === schema && cp.table === table && cp.name === policy.name);
148
+ if (!found) {
149
+ push("error", "missing-policy", `${policy.table}.${policy.name}`, `声明的策略 ${policy.name} 在表 ${policy.table} 的 catalog 中不存在`);
150
+ }
151
+ }
152
+ const declaredPolicyKeys = new Set(module.policies.map((p) => `${p.table}::${p.name}`));
153
+ for (const cp of catalog.policies) {
154
+ const qualified = `${cp.schema}.${cp.table}`;
155
+ if (ownedTables.has(qualified) && !declaredPolicyKeys.has(`${qualified}::${cp.name}`)) {
156
+ push("warn", "undeclared-policy", `${qualified}.${cp.name}`, `归属表 ${qualified} 上存在未声明的策略 ${cp.name},可能发生漂移`);
157
+ }
158
+ }
159
+ for (const fn of module.functions) {
160
+ const [schema, name] = splitQualifiedName(fn.name);
161
+ const cf = catalog.functions.find((f) => f.schema === schema && f.name === name);
162
+ if (!cf) {
163
+ push("error", "missing-function", fn.name, `声明的函数 ${fn.name} 在 catalog 中不存在`);
164
+ continue;
165
+ }
166
+ if (cf.security !== fn.security) {
167
+ push("warn", "security-mismatch", fn.name, `函数 ${fn.name} 声明为 security ${fn.security},catalog 实际为 ${cf.security}`);
168
+ }
169
+ const effectiveDefiner = fn.security === "definer" || cf.security === "definer";
170
+ if (effectiveDefiner && !isFixedSearchPath(cf.searchPath)) {
171
+ push("error", "definer-without-search-path", fn.name, `security definer 函数 ${fn.name} 未设置固定 search_path(当前: ${cf.searchPath ?? "未设置"})`);
172
+ }
173
+ }
174
+ for (const table of module.tables) {
175
+ const [schema, name] = splitQualifiedName(table);
176
+ const ct = catalog.tables.find((t) => t.schema === schema && t.name === name);
177
+ if (ct && !ct.rlsEnabled) {
178
+ push("error", "rls-disabled", table, `归属表 ${table} 未开启行级安全(relrowsecurity = false)`);
179
+ }
180
+ }
181
+ for (const grant of catalog.grants) {
182
+ const qualified = `${grant.objectSchema}.${grant.objectName}`;
183
+ if (ownedTables.has(qualified) && grant.grantee.toUpperCase() === "PUBLIC") {
184
+ push("error", "wildcard-grant", qualified, `归属表 ${qualified} 存在授予 PUBLIC 的 ${grant.privilege} 权限`);
185
+ }
186
+ }
187
+ for (const grant of module.grants) {
188
+ const [schema, name] = splitQualifiedName(grant.object);
189
+ const found = catalog.grants.some((cg) => cg.objectSchema === schema && cg.objectName === name && cg.privilege.toLowerCase() === grant.privilege.toLowerCase() && cg.grantee.toLowerCase() === grant.role.toLowerCase());
190
+ if (!found) {
191
+ push("warn", "grant-drift", grant.object, `声明的授权 ${grant.privilege} ON ${grant.object} TO ${grant.role} 在 catalog 中不存在`);
192
+ }
193
+ }
194
+ return {
195
+ module: module.name,
196
+ issues,
197
+ ok: !issues.some((issue) => issue.severity === "error")
198
+ };
199
+ }
200
+ // src/lint.ts
201
+ var SECURITY_DEFINER_RE = /\bsecurity\s+definer\b/i;
202
+ var SET_SEARCH_PATH_RE = /\bset\s+search_path\b/i;
203
+ var GRANT_TO_PUBLIC_RE = /\bgrant\b[^;]*\bto\s+public\b/i;
204
+ var DROP_WITHOUT_IF_EXISTS_RE = /\bdrop\s+(?:table|column)\s+(?!if\s+exists\b)/i;
205
+ var ENABLE_RLS_RE = /\benable\s+row\s+level\s+security\b/i;
206
+ function lineOf(sql, index) {
207
+ let line = 1;
208
+ for (let i = 0;i < index; i += 1) {
209
+ if (sql.charCodeAt(i) === 10)
210
+ line += 1;
211
+ }
212
+ return line;
213
+ }
214
+ function lintSql(sql, file) {
215
+ const issues = [];
216
+ const definer = SECURITY_DEFINER_RE.exec(sql);
217
+ if (definer && !SET_SEARCH_PATH_RE.test(sql)) {
218
+ issues.push({
219
+ severity: "error",
220
+ code: "definer-no-search-path",
221
+ message: "security definer 函数必须显式 set search_path,避免 search_path 劫持",
222
+ file,
223
+ line: lineOf(sql, definer.index)
224
+ });
225
+ }
226
+ const grantPublic = GRANT_TO_PUBLIC_RE.exec(sql);
227
+ if (grantPublic) {
228
+ issues.push({
229
+ severity: "error",
230
+ code: "grant-to-public",
231
+ message: "禁止将权限授予 PUBLIC 角色",
232
+ file,
233
+ line: lineOf(sql, grantPublic.index)
234
+ });
235
+ }
236
+ const drop = DROP_WITHOUT_IF_EXISTS_RE.exec(sql);
237
+ if (drop) {
238
+ issues.push({
239
+ severity: "warn",
240
+ code: "drop-without-if-exists",
241
+ message: "drop table/column 建议使用 if exists,保证迁移可重入",
242
+ file,
243
+ line: lineOf(sql, drop.index)
244
+ });
245
+ }
246
+ return issues;
247
+ }
248
+ async function lintModule(module, readFile) {
249
+ const issues = [];
250
+ const sources = new Set;
251
+ for (const decl of [
252
+ ...module.policies,
253
+ ...module.functions,
254
+ ...module.triggers,
255
+ ...module.grants
256
+ ]) {
257
+ sources.add(decl.source);
258
+ }
259
+ const contents = new Map;
260
+ await Promise.all([...sources].map(async (path) => {
261
+ contents.set(path, await readFile(path));
262
+ }));
263
+ for (const [file, sql] of contents) {
264
+ issues.push(...lintSql(sql, file));
265
+ }
266
+ if (module.policies.length > 0) {
267
+ const anyEnable = module.policies.some((policy) => ENABLE_RLS_RE.test(contents.get(policy.source) ?? ""));
268
+ if (!anyEnable) {
269
+ issues.push({
270
+ severity: "warn",
271
+ code: "missing-rls-enable",
272
+ message: `模块 ${module.name} 声明了 ${module.policies.length} 条策略,但所有策略源文件都没有 enable row level security`,
273
+ file: module.policies[0].source
274
+ });
275
+ }
276
+ }
277
+ for (const policy of module.policies) {
278
+ if (!policy.tests || policy.tests.length === 0) {
279
+ issues.push({
280
+ severity: "warn",
281
+ code: "policy-without-test",
282
+ message: `策略 ${policy.name} 未声明测试文件`,
283
+ file: policy.source
284
+ });
285
+ }
286
+ }
287
+ for (const fn of module.functions) {
288
+ if (!fn.tests || fn.tests.length === 0) {
289
+ issues.push({
290
+ severity: "warn",
291
+ code: "policy-without-test",
292
+ message: `函数 ${fn.name} 未声明测试文件`,
293
+ file: fn.source
294
+ });
295
+ }
296
+ }
297
+ return issues;
298
+ }
299
+ // src/manifest.ts
300
+ function buildDatabaseManifest(modules) {
301
+ return {
302
+ version: 1,
303
+ modules: modules.map((module) => ({
304
+ name: module.name,
305
+ tables: module.tables,
306
+ policies: module.policies,
307
+ functions: module.functions,
308
+ triggers: module.triggers,
309
+ grants: module.grants
310
+ }))
311
+ };
312
+ }
313
+ function describePolicy(moduleName, policy) {
314
+ const lines = [
315
+ `对象: ${policy.table}.${policy.name}`,
316
+ "类型: 策略 (policy)",
317
+ `所属模块: ${moduleName}`,
318
+ `表: ${policy.table}`,
319
+ `操作: ${policy.operation}`,
320
+ `角色: ${policy.roles.join(", ")}`,
321
+ `源文件: ${policy.source}`
322
+ ];
323
+ if (policy.tests && policy.tests.length > 0)
324
+ lines.push(`测试: ${policy.tests.join(", ")}`);
325
+ return lines.join(`
326
+ `);
327
+ }
328
+ function describeFunction(moduleName, fn) {
329
+ const lines = [
330
+ `对象: ${fn.name}`,
331
+ "类型: 函数 (function)",
332
+ `所属模块: ${moduleName}`,
333
+ `源文件: ${fn.source}`,
334
+ `安全模式: ${fn.security}`
335
+ ];
336
+ if (fn.permission)
337
+ lines.push(`权限: ${fn.permission}`);
338
+ if (fn.transaction)
339
+ lines.push(`事务: ${fn.transaction}`);
340
+ if (fn.audit)
341
+ lines.push(`审计: ${fn.audit}`);
342
+ if (fn.idempotency)
343
+ lines.push(`幂等: ${fn.idempotency}`);
344
+ if (fn.tests && fn.tests.length > 0)
345
+ lines.push(`测试: ${fn.tests.join(", ")}`);
346
+ return lines.join(`
347
+ `);
348
+ }
349
+ function describeTrigger(moduleName, trigger) {
350
+ return [
351
+ `对象: ${trigger.name}`,
352
+ "类型: 触发器 (trigger)",
353
+ `所属模块: ${moduleName}`,
354
+ `表: ${trigger.table}`,
355
+ `源文件: ${trigger.source}`
356
+ ].join(`
357
+ `);
358
+ }
359
+ function describeGrant(moduleName, grant) {
360
+ return [
361
+ `对象: ${grant.object}`,
362
+ "类型: 授权 (grant)",
363
+ `所属模块: ${moduleName}`,
364
+ `权限: ${grant.privilege}`,
365
+ `角色: ${grant.role}`,
366
+ `源文件: ${grant.source}`
367
+ ].join(`
368
+ `);
369
+ }
370
+ function explainObject(manifest, name) {
371
+ for (const module of manifest.modules) {
372
+ for (const policy of module.policies) {
373
+ if (policy.name === name || `${policy.table}.${policy.name}` === name) {
374
+ return describePolicy(module.name, policy);
375
+ }
376
+ }
377
+ for (const fn of module.functions) {
378
+ if (fn.name === name)
379
+ return describeFunction(module.name, fn);
380
+ }
381
+ for (const trigger of module.triggers) {
382
+ if (trigger.name === name || `${trigger.table}.${trigger.name}` === name) {
383
+ return describeTrigger(module.name, trigger);
384
+ }
385
+ }
386
+ for (const table of module.tables) {
387
+ if (table === name) {
388
+ return [
389
+ `对象: ${table}`,
390
+ "类型: 表 (table)",
391
+ `所属模块: ${module.name}`
392
+ ].join(`
393
+ `);
394
+ }
395
+ }
396
+ for (const grant of module.grants) {
397
+ if (grant.object === name)
398
+ return describeGrant(module.name, grant);
399
+ }
400
+ }
401
+ return `未找到对象: ${name}`;
402
+ }
403
+ export {
404
+ buildDatabaseManifest,
405
+ defineDatabaseModule,
406
+ explainObject,
407
+ extractSearchPath,
408
+ lintModule,
409
+ lintSql,
410
+ readCatalog,
411
+ reconcileModule
412
+ };
package/dist/lint.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Lint:对 SQL 源文本做正则级静态分析,无需数据库连接。
3
+ */
4
+ import type { DatabaseModule } from './module.js';
5
+ export interface LintIssue {
6
+ severity: 'error' | 'warn';
7
+ code: string;
8
+ message: string;
9
+ file: string;
10
+ line?: number;
11
+ }
12
+ export declare function lintSql(sql: string, file: string): LintIssue[];
13
+ export declare function lintModule(module: DatabaseModule, readFile: (path: string) => Promise<string>): Promise<LintIssue[]>;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Manifest:把若干 DatabaseModule 汇总为可序列化的数据库治理清单,
3
+ * 并提供 explainObject 做人类可读的对象解释。
4
+ */
5
+ import type { DatabaseModule, FunctionDecl, GrantDecl, PolicyDecl, TriggerDecl } from './module.js';
6
+ export interface DatabaseManifestModule {
7
+ name: string;
8
+ tables: string[];
9
+ policies: PolicyDecl[];
10
+ functions: FunctionDecl[];
11
+ triggers: TriggerDecl[];
12
+ grants: GrantDecl[];
13
+ }
14
+ export interface DatabaseManifest {
15
+ version: 1;
16
+ modules: DatabaseManifestModule[];
17
+ }
18
+ export declare function buildDatabaseManifest(modules: DatabaseModule[]): DatabaseManifest;
19
+ /**
20
+ * 按名字解释一个对象:策略(name 或 table.name)、函数(schema.name)、
21
+ * 触发器(name)、归属表(schema.name)、授权对象(schema.name,最后兜底)。
22
+ */
23
+ export declare function explainObject(manifest: DatabaseManifest, name: string): string;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * 模块声明:数据库治理的一等资源(RLS 策略 / RPC 函数 / 触发器 / 授权)。
3
+ * driver 无关,纯声明层。
4
+ */
5
+ export type PolicyOperation = 'select' | 'insert' | 'update' | 'delete' | 'all';
6
+ export interface PolicyDecl {
7
+ /** 策略名,如 cases_select */
8
+ name: string;
9
+ /** 带 schema 的表名:public.cases */
10
+ table: string;
11
+ operation: PolicyOperation;
12
+ /** 适用角色,如 ['authenticated'] */
13
+ roles: string[];
14
+ /** SQL 源文件相对路径 */
15
+ source: string;
16
+ /** 测试文件相对路径 */
17
+ tests?: string[];
18
+ }
19
+ export interface FunctionDecl {
20
+ /** 带 schema 的函数名:public.case_create */
21
+ name: string;
22
+ source: string;
23
+ /** 业务权限标识,如 case.create */
24
+ permission?: string;
25
+ transaction?: 'required' | 'none';
26
+ security: 'invoker' | 'definer';
27
+ audit?: string;
28
+ idempotency?: string;
29
+ tests?: string[];
30
+ }
31
+ export interface TriggerDecl {
32
+ name: string;
33
+ /** 带 schema 的表名 */
34
+ table: string;
35
+ source: string;
36
+ }
37
+ export interface GrantDecl {
38
+ /** 带 schema 的对象名:public.cases */
39
+ object: string;
40
+ privilege: string;
41
+ role: string;
42
+ source: string;
43
+ }
44
+ /** drizzle Table 的内部结构形状(仅类型层兼容,不 import drizzle-orm) */
45
+ export interface DrizzleTableLike {
46
+ _: {
47
+ name: string;
48
+ schema?: string;
49
+ };
50
+ }
51
+ export type TableRef = string | DrizzleTableLike;
52
+ export interface DatabaseModuleOptions {
53
+ name: string;
54
+ /** 归属表:drizzle 表对象或带 schema 表名均可 */
55
+ tables?: TableRef[];
56
+ policies?: PolicyDecl[];
57
+ functions?: FunctionDecl[];
58
+ triggers?: TriggerDecl[];
59
+ grants?: GrantDecl[];
60
+ }
61
+ export interface DatabaseModule {
62
+ name: string;
63
+ /** 归一化为 'schema.name' 形式的表名 */
64
+ tables: string[];
65
+ policies: PolicyDecl[];
66
+ functions: FunctionDecl[];
67
+ triggers: TriggerDecl[];
68
+ grants: GrantDecl[];
69
+ }
70
+ export declare function defineDatabaseModule(options: DatabaseModuleOptions): DatabaseModule;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * 对账:声明式 Manifest(DatabaseModule)与 PostgreSQL 真实 Catalog 比对。
3
+ */
4
+ import type { DatabaseCatalog } from './catalog.js';
5
+ import type { DatabaseModule } from './module.js';
6
+ export interface ReconcileIssue {
7
+ severity: 'error' | 'warn';
8
+ code: string;
9
+ message: string;
10
+ /** 涉及对象名 */
11
+ object: string;
12
+ }
13
+ export interface ReconcileReport {
14
+ module: string;
15
+ issues: ReconcileIssue[];
16
+ /** 无 error 级问题 */
17
+ ok: boolean;
18
+ }
19
+ export declare function reconcileModule(module: DatabaseModule, catalog: DatabaseCatalog): ReconcileReport;
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@supacloud/db",
3
+ "version": "0.1.0",
4
+ "description": "Database governance layer for SupaCloud: RLS policies, RPC functions and grants as first-class resources, with manifest/catalog reconciliation",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "build": "bun run clean && bun run build:js && bun run build:types",
22
+ "build:js": "bun build src/index.ts --outdir dist --target node",
23
+ "build:types": "tsc -p tsconfig.json --emitDeclarationOnly",
24
+ "clean": "rm -rf dist",
25
+ "prepublishOnly": "bun run build",
26
+ "test": "bun test",
27
+ "typecheck": "tsc -p tsconfig.json --noEmit",
28
+ "typecheck:test": "tsc -p tsconfig.test.json --noEmit"
29
+ },
30
+ "keywords": [
31
+ "supacloud",
32
+ "database",
33
+ "governance",
34
+ "rls",
35
+ "postgresql"
36
+ ],
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/vibeunion/supacloud.git",
41
+ "directory": "packages/db"
42
+ },
43
+ "devDependencies": {
44
+ "@types/bun": "^1.4.0",
45
+ "typescript": "^7.0.2"
46
+ }
47
+ }