@supacloud/db 0.1.0 → 0.2.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 CHANGED
@@ -16,7 +16,8 @@ db/
16
16
  ```
17
17
 
18
18
  ```ts
19
- import { defineDatabaseModule } from '@supacloud/db';
19
+ import { createDatabaseAccessBoundary, defineDatabaseModule } from '@supacloud/db';
20
+ import { requireTrustedIdentity } from '@supacloud/elysia';
20
21
  import { cases } from './schema'; // drizzle 表对象
21
22
 
22
23
  export const casesModule = defineDatabaseModule({
@@ -57,6 +58,26 @@ export const casesModule = defineDatabaseModule({
57
58
  | `lintSql(sql, file)` / `lintModule(module, readFile)` | 纯 SQL 文本静态分析,无需数据库 |
58
59
  | `buildDatabaseManifest(modules)` | 汇总模块为可 JSON 序列化的 `DatabaseManifest`(version 1) |
59
60
  | `explainObject(manifest, name)` | 人类可读地解释对象的所属模块、类型、源文件、权限、测试 |
61
+ | `createDatabaseAccessBoundary(options)` | 统一用户 RLS 客户端与显式 service-role 客户端的访问边界 |
62
+ | `planModule(module, readFile)` | 把模块声明编译为有序 `ModulePlan`(step 依赖序:function → policy → trigger → grant),含 sha256 与 lint 风险 |
63
+ | `applyModulePlan(executor, plan)` | 按 plan 落库:账本幂等 + advisory lock + 单事务 + catalog 回读验证 |
64
+
65
+ ### 数据库访问边界
66
+
67
+ 业务请求只应通过 `forUser({ subject, accessToken })` 创建 RLS-preserving 客户端;
68
+ 后台任务才可以通过 `forService("declared-reason")` 获取缓存的 service-role 客户端。
69
+ service-role 原因必须预先加入 `allowedServiceReasons`,缺少身份、令牌或理由时会 fail-closed。
70
+
71
+ ```ts
72
+ const database = createDatabaseAccessBoundary({
73
+ createUserClient: ({ accessToken }) => createSupabaseClient(accessToken),
74
+ createServiceClient: () => createServiceRoleClient(),
75
+ allowedServiceReasons: ["scheduled-worker", "migration-check"],
76
+ });
77
+
78
+ const userDb = await database.forUser(requireTrustedIdentity(requestContext));
79
+ const workerDb = await database.forService("scheduled-worker");
80
+ ```
60
81
 
61
82
  ## 诊断码
62
83
 
@@ -66,7 +87,9 @@ export const casesModule = defineDatabaseModule({
66
87
  | --- | --- | --- |
67
88
  | `missing-policy` | error | 声明的策略在 catalog 中不存在 |
68
89
  | `missing-function` | error | 声明的 RPC 函数在 catalog 中不存在 |
90
+ | `missing-trigger` | error | 声明的触发器在 catalog 中不存在(按 schema.table + name 匹配) |
69
91
  | `undeclared-policy` | warn | 归属表上存在 manifest 未声明的策略(漂移) |
92
+ | `undeclared-trigger` | warn | 归属表上存在 manifest 未声明的触发器(含已禁用的,漂移) |
70
93
  | `rls-disabled` | error | 归属表 `relrowsecurity = false` |
71
94
  | `definer-without-search-path` | error | security definer 函数未设置固定 search_path(含空元素或 `pg_temp` 也算不固定) |
72
95
  | `security-mismatch` | warn | 声明的 invoker/definer 与 catalog 实际不一致 |
@@ -83,8 +106,25 @@ export const casesModule = defineDatabaseModule({
83
106
  | `grant-to-public` | error | `grant ... to public` |
84
107
  | `missing-rls-enable` | warn | 声明了策略但所有策略源文件都没有 `enable row level security` |
85
108
  | `drop-without-if-exists` | warn | `drop table/column` 缺少 `if exists` |
109
+ | `non-idempotent-policy` | warn | `create policy` 前缺少 `drop policy if exists`(PostgreSQL CREATE POLICY 无 IF NOT EXISTS,不先 drop 就不可重复执行) |
86
110
  | `policy-without-test` | warn | 声明的策略/函数没有 `tests` 条目 |
87
111
 
112
+ ## Plan / Apply
113
+
114
+ `planModule` 把模块声明编译为 `ModulePlan`(version 1):每个声明对象(函数/策略/触发器/授权)对应一个 `PlanStep`,按依赖序 **function → policy → trigger → grant** 排列;每个 step 携带源文件内容的 sha256、`lintSql` 静态分析得出的 `risk`(error 级 lint 原样保留 severity=error),plan 级 `digest` 为全部 step sha256 的组合哈希。step 的 `name` 是对象标识:函数为 schema 限定名,策略/触发器为 `table.name`,授权为 `object:privilege:role`。
115
+
116
+ `applyModulePlan` 的语义:
117
+
118
+ 1. plan 含任何 error 级 risk → 直接抛错拒绝执行,不触碰数据库。
119
+ 2. 确保账本:`create schema if not exists _supacloud` + `create table if not exists _supacloud.db_object_ledger(object_identity text primary key, module text, sha256 text, applied_at timestamptz default now())`。
120
+ 3. `pg_advisory_lock(hashtext('supacloud-db-apply'))` 串行化并发 apply,`finally` 中 unlock。
121
+ 4. 单事务内逐 step 执行:`object_identity = ${kind}:${name}` 查账本,sha256 一致则 `skipped`;否则执行 step.sql 并 upsert 账本。任何 step 失败 → ROLLBACK 并返回 `failed`;全成功 → COMMIT。
122
+ 5. 提交后 `readCatalog` 回读验证声明对象真实存在,结果进 `verified`,缺失进 `failed`。
123
+
124
+ **幂等要求**:step.sql 必须可重复执行(`create or replace function`、`drop policy if exists` + `create policy`、`drop trigger if exists` + `create trigger`、`grant` 天然幂等)。sha256 一致即跳过,因此改源文件才会触发重放。executor 提供可选 `transaction(fn)` 时用它管理事务,否则退化为顺序执行 `begin/commit/rollback` 语句。
125
+
126
+ **与 migration 的边界**:本执行器只管模块声明的**可重复 SQL 对象**(函数/策略/触发器/授权),不做表结构变更 —— 建表、加列等表结构演进仍走前向 migration(Drizzle/SQL migration 文件)。
127
+
88
128
  ## 与 Supabase / PostgreSQL 的关系
89
129
 
90
130
  SupaCloud 兼容 Supabase 的托管 PostgreSQL 模型:`authenticated` / `anon` / `service_role` 角色、RLS 策略、`security definer` RPC 都是治理对象。本包读取的系统目录(`pg_class` / `pg_policy` / `pg_proc` / `information_schema`)是标准 PostgreSQL 接口,因此同样适用于自托管 PostgreSQL;针对 Supabase 风格的角色与 schema 约定没有硬编码依赖。
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Driver-independent database access boundary for request-scoped user clients
3
+ * and explicitly authorized service-role clients.
4
+ */
5
+ export interface AuthenticatedDatabaseIdentity {
6
+ subject: string;
7
+ accessToken: string;
8
+ }
9
+ export interface DatabaseAccessBoundaryOptions<UserClient, ServiceClient> {
10
+ createUserClient(identity: AuthenticatedDatabaseIdentity): UserClient | Promise<UserClient>;
11
+ createServiceClient?: () => ServiceClient | Promise<ServiceClient>;
12
+ /** Service-role access is denied unless the supplied reason is allowlisted. */
13
+ allowedServiceReasons?: readonly string[];
14
+ }
15
+ export interface DatabaseAccessBoundary<UserClient, ServiceClient> {
16
+ /** Create an RLS-preserving client for the verified request identity. */
17
+ forUser(identity: AuthenticatedDatabaseIdentity): Promise<UserClient>;
18
+ /** Return a cached service-role client for an explicitly allowlisted reason. */
19
+ forService(reason: string): Promise<ServiceClient>;
20
+ }
21
+ export type DatabaseAccessErrorCode = "DATABASE_IDENTITY_REQUIRED" | "DATABASE_ACCESS_TOKEN_REQUIRED" | "SERVICE_ROLE_UNAVAILABLE" | "SERVICE_ROLE_REASON_REQUIRED" | "SERVICE_ROLE_REASON_NOT_ALLOWED";
22
+ export declare class DatabaseAccessError extends Error {
23
+ readonly expose: true;
24
+ readonly code: DatabaseAccessErrorCode;
25
+ readonly status: number;
26
+ constructor(code: DatabaseAccessErrorCode, status: number, message: string);
27
+ }
28
+ export declare function createDatabaseAccessBoundary<UserClient, ServiceClient = never>(options: DatabaseAccessBoundaryOptions<UserClient, ServiceClient>): DatabaseAccessBoundary<UserClient, ServiceClient>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Apply:把 ModulePlan 落到数据库 —— 账本幂等 + advisory lock + 单事务 + catalog 回读验证。
3
+ * 边界与 plan 一致:只管模块声明的可重复 SQL 对象,不做表结构 migration。
4
+ */
5
+ import { type QueryExecutor } from './catalog.js';
6
+ import type { ModulePlan } from './plan.js';
7
+ export interface ApplyResult {
8
+ module: string;
9
+ /** 实际执行了 SQL 的 step 名 */
10
+ applied: string[];
11
+ /** ledger 哈希一致跳过的 */
12
+ skipped: string[];
13
+ /** 应用后在 catalog 中确认存在的对象 */
14
+ verified: string[];
15
+ failed?: {
16
+ step: string;
17
+ error: string;
18
+ };
19
+ }
20
+ export declare function applyModulePlan(executor: QueryExecutor, plan: ModulePlan): Promise<ApplyResult>;
package/dist/catalog.d.ts CHANGED
@@ -5,6 +5,11 @@
5
5
  import type { PolicyOperation } from './module.js';
6
6
  export interface QueryExecutor {
7
7
  query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
8
+ /**
9
+ * 可选事务封装:提供时 apply 用它包裹 begin/commit/rollback;
10
+ * 缺省时退化为在 executor 上顺序执行 begin/commit/rollback 语句(mock 友好)。
11
+ */
12
+ transaction?<T>(fn: (executor: QueryExecutor) => Promise<T>): Promise<T>;
8
13
  }
9
14
  export interface CatalogTable {
10
15
  schema: string;
@@ -28,6 +33,13 @@ export interface CatalogFunction {
28
33
  searchPath: string | null;
29
34
  language: string;
30
35
  }
36
+ export interface CatalogTrigger {
37
+ schema: string;
38
+ table: string;
39
+ name: string;
40
+ /** tgenabled !== 'D'(未被 disable) */
41
+ enabled: boolean;
42
+ }
31
43
  export interface CatalogGrant {
32
44
  objectSchema: string;
33
45
  objectName: string;
@@ -38,6 +50,7 @@ export interface DatabaseCatalog {
38
50
  tables: CatalogTable[];
39
51
  policies: CatalogPolicy[];
40
52
  functions: CatalogFunction[];
53
+ triggers: CatalogTrigger[];
41
54
  grants: CatalogGrant[];
42
55
  }
43
56
  /** 从 proconfig(text[])中提取 search_path=... 配置,无则 null */
package/dist/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
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';
2
+ export { extractSearchPath, readCatalog, type CatalogFunction, type CatalogGrant, type CatalogPolicy, type CatalogTable, type CatalogTrigger, type DatabaseCatalog, type QueryExecutor, } from './catalog.js';
3
+ export { reconcileModule, splitQualifiedName, type ReconcileIssue, type ReconcileReport, } from './reconcile.js';
4
4
  export { lintModule, lintSql, type LintIssue } from './lint.js';
5
+ export { planModule, type ModulePlan, type PlanStep } from './plan.js';
6
+ export { applyModulePlan, type ApplyResult } from './apply.js';
5
7
  export { buildDatabaseManifest, explainObject, type DatabaseManifest, type DatabaseManifestModule, } from './manifest.js';
8
+ export { createDatabaseAccessBoundary, DatabaseAccessError, type AuthenticatedDatabaseIdentity, type DatabaseAccessBoundary, type DatabaseAccessBoundaryOptions, type DatabaseAccessErrorCode, } from './access.js';
package/dist/index.js CHANGED
@@ -56,6 +56,18 @@ JOIN pg_language l ON l.oid = p.prolang
56
56
  WHERE n.nspname = ANY($1)
57
57
  ORDER BY n.nspname, p.proname
58
58
  `;
59
+ var TRIGGERS_SQL = `
60
+ SELECT n.nspname AS schema,
61
+ c.relname AS table,
62
+ t.tgname AS name,
63
+ t.tgenabled <> 'D' AS enabled
64
+ FROM pg_trigger t
65
+ JOIN pg_class c ON c.oid = t.tgrelid
66
+ JOIN pg_namespace n ON n.oid = c.relnamespace
67
+ WHERE NOT t.tgisinternal
68
+ AND n.nspname = ANY($1)
69
+ ORDER BY n.nspname, c.relname, t.tgname
70
+ `;
59
71
  var GRANTS_SQL = `
60
72
  SELECT table_schema AS object_schema,
61
73
  table_name AS object_name,
@@ -88,10 +100,11 @@ function extractSearchPath(config) {
88
100
  }
89
101
  async function readCatalog(executor, schemas = ["public"]) {
90
102
  const params = [schemas];
91
- const [tableRows, policyRows, functionRows, grantRows] = await Promise.all([
103
+ const [tableRows, policyRows, functionRows, triggerRows, grantRows] = await Promise.all([
92
104
  executor.query(TABLES_SQL, params),
93
105
  executor.query(POLICIES_SQL, params),
94
106
  executor.query(FUNCTIONS_SQL, params),
107
+ executor.query(TRIGGERS_SQL, params),
95
108
  executor.query(GRANTS_SQL, params)
96
109
  ]);
97
110
  return {
@@ -117,6 +130,12 @@ async function readCatalog(executor, schemas = ["public"]) {
117
130
  searchPath: extractSearchPath(row.config),
118
131
  language: row.language
119
132
  })),
133
+ triggers: triggerRows.map((row) => ({
134
+ schema: row.schema,
135
+ table: row.table,
136
+ name: row.name,
137
+ enabled: row.enabled
138
+ })),
120
139
  grants: grantRows.map((row) => ({
121
140
  objectSchema: row.object_schema,
122
141
  objectName: row.object_name,
@@ -171,6 +190,20 @@ function reconcileModule(module, catalog) {
171
190
  push("error", "definer-without-search-path", fn.name, `security definer 函数 ${fn.name} 未设置固定 search_path(当前: ${cf.searchPath ?? "未设置"})`);
172
191
  }
173
192
  }
193
+ for (const trigger of module.triggers) {
194
+ const [schema, table] = splitQualifiedName(trigger.table);
195
+ const found = catalog.triggers.some((ct) => ct.schema === schema && ct.table === table && ct.name === trigger.name);
196
+ if (!found) {
197
+ push("error", "missing-trigger", `${trigger.table}.${trigger.name}`, `声明的触发器 ${trigger.name} 在表 ${trigger.table} 的 catalog 中不存在`);
198
+ }
199
+ }
200
+ const declaredTriggerKeys = new Set(module.triggers.map((t) => `${t.table}::${t.name}`));
201
+ for (const ct of catalog.triggers) {
202
+ const qualified = `${ct.schema}.${ct.table}`;
203
+ if (ownedTables.has(qualified) && !declaredTriggerKeys.has(`${qualified}::${ct.name}`)) {
204
+ push("warn", "undeclared-trigger", `${qualified}.${ct.name}`, `归属表 ${qualified} 上存在未声明的触发器 ${ct.name}${ct.enabled ? "" : "(已禁用)"},可能发生漂移`);
205
+ }
206
+ }
174
207
  for (const table of module.tables) {
175
208
  const [schema, name] = splitQualifiedName(table);
176
209
  const ct = catalog.tables.find((t) => t.schema === schema && t.name === name);
@@ -203,6 +236,8 @@ var SET_SEARCH_PATH_RE = /\bset\s+search_path\b/i;
203
236
  var GRANT_TO_PUBLIC_RE = /\bgrant\b[^;]*\bto\s+public\b/i;
204
237
  var DROP_WITHOUT_IF_EXISTS_RE = /\bdrop\s+(?:table|column)\s+(?!if\s+exists\b)/i;
205
238
  var ENABLE_RLS_RE = /\benable\s+row\s+level\s+security\b/i;
239
+ var CREATE_POLICY_RE = /\bcreate\s+policy\b/i;
240
+ var DROP_POLICY_IF_EXISTS_RE = /\bdrop\s+policy\s+if\s+exists\b/i;
206
241
  function lineOf(sql, index) {
207
242
  let line = 1;
208
243
  for (let i = 0;i < index; i += 1) {
@@ -243,6 +278,19 @@ function lintSql(sql, file) {
243
278
  line: lineOf(sql, drop.index)
244
279
  });
245
280
  }
281
+ const createPolicy = CREATE_POLICY_RE.exec(sql);
282
+ if (createPolicy) {
283
+ const dropPolicy = DROP_POLICY_IF_EXISTS_RE.exec(sql);
284
+ if (!dropPolicy || dropPolicy.index > createPolicy.index) {
285
+ issues.push({
286
+ severity: "warn",
287
+ code: "non-idempotent-policy",
288
+ message: "create policy 前缺少 drop policy if exists,策略不可重复执行",
289
+ file,
290
+ line: lineOf(sql, createPolicy.index)
291
+ });
292
+ }
293
+ }
246
294
  return issues;
247
295
  }
248
296
  async function lintModule(module, readFile) {
@@ -296,6 +344,193 @@ async function lintModule(module, readFile) {
296
344
  }
297
345
  return issues;
298
346
  }
347
+ // src/plan.ts
348
+ async function sha256Hex(content) {
349
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(content));
350
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
351
+ }
352
+ async function planModule(module, readFile) {
353
+ const contents = new Map;
354
+ const load = async (path) => {
355
+ const cached = contents.get(path);
356
+ if (cached !== undefined)
357
+ return cached;
358
+ const sql = await readFile(path);
359
+ contents.set(path, sql);
360
+ return sql;
361
+ };
362
+ const steps = [];
363
+ const addStep = async (kind, name, source) => {
364
+ const sql = await load(source);
365
+ steps.push({
366
+ kind,
367
+ name,
368
+ source,
369
+ sha256: await sha256Hex(sql),
370
+ sql,
371
+ risk: lintSql(sql, source).map(({ severity, code, message }) => ({
372
+ severity,
373
+ code,
374
+ message
375
+ }))
376
+ });
377
+ };
378
+ for (const fn of module.functions) {
379
+ await addStep("function", fn.name, fn.source);
380
+ }
381
+ for (const policy of module.policies) {
382
+ await addStep("policy", `${policy.table}.${policy.name}`, policy.source);
383
+ }
384
+ for (const trigger of module.triggers) {
385
+ await addStep("trigger", `${trigger.table}.${trigger.name}`, trigger.source);
386
+ }
387
+ for (const grant of module.grants) {
388
+ await addStep("grant", `${grant.object}:${grant.privilege}:${grant.role}`, grant.source);
389
+ }
390
+ return {
391
+ version: 1,
392
+ module: module.name,
393
+ createdAt: new Date().toISOString(),
394
+ steps,
395
+ digest: await sha256Hex(steps.map((step) => step.sha256).join(`
396
+ `))
397
+ };
398
+ }
399
+ // src/apply.ts
400
+ var LEDGER_SCHEMA_SQL = "create schema if not exists _supacloud";
401
+ var LEDGER_TABLE_SQL = `create table if not exists _supacloud.db_object_ledger(
402
+ object_identity text primary key,
403
+ module text not null,
404
+ sha256 text not null,
405
+ applied_at timestamptz not null default now()
406
+ )`;
407
+ var LOCK_SQL = `select pg_advisory_lock(hashtext('supacloud-db-apply'))`;
408
+ var UNLOCK_SQL = `select pg_advisory_unlock(hashtext('supacloud-db-apply'))`;
409
+ var LEDGER_READ_SQL = "select sha256 from _supacloud.db_object_ledger where object_identity = $1";
410
+ var LEDGER_UPSERT_SQL = `insert into _supacloud.db_object_ledger (object_identity, module, sha256)
411
+ values ($1, $2, $3)
412
+ on conflict (object_identity) do update
413
+ set module = excluded.module, sha256 = excluded.sha256, applied_at = now()`;
414
+
415
+ class StepFailure extends Error {
416
+ step;
417
+ constructor(step, cause) {
418
+ super(cause instanceof Error ? cause.message : String(cause));
419
+ this.step = step;
420
+ }
421
+ }
422
+ function toFailure(error) {
423
+ if (error instanceof StepFailure)
424
+ return { step: error.step, error: error.message };
425
+ return { step: "(unknown)", error: error instanceof Error ? error.message : String(error) };
426
+ }
427
+ async function runInTransaction(executor, fn) {
428
+ if (executor.transaction) {
429
+ try {
430
+ await executor.transaction(fn);
431
+ return;
432
+ } catch (error) {
433
+ return toFailure(error);
434
+ }
435
+ }
436
+ await executor.query("begin");
437
+ try {
438
+ await fn(executor);
439
+ } catch (error) {
440
+ await executor.query("rollback");
441
+ return toFailure(error);
442
+ }
443
+ await executor.query("commit");
444
+ return;
445
+ }
446
+ function existsInCatalog(step, catalog) {
447
+ switch (step.kind) {
448
+ case "function": {
449
+ const [schema, name] = splitQualifiedName(step.name);
450
+ return catalog.functions.some((f) => f.schema === schema && f.name === name);
451
+ }
452
+ case "policy": {
453
+ const [schema, table, ...rest] = step.name.split(".");
454
+ const name = rest.join(".");
455
+ return catalog.policies.some((p) => p.schema === schema && p.table === table && p.name === name);
456
+ }
457
+ case "trigger": {
458
+ const [schema, table, ...rest] = step.name.split(".");
459
+ const name = rest.join(".");
460
+ return catalog.triggers.some((t) => t.schema === schema && t.table === table && t.name === name);
461
+ }
462
+ case "grant": {
463
+ const [object, privilege, role] = step.name.split(":");
464
+ const [schema, name] = splitQualifiedName(object);
465
+ return catalog.grants.some((g) => g.objectSchema === schema && g.objectName === name && g.privilege.toLowerCase() === privilege.toLowerCase() && g.grantee.toLowerCase() === role.toLowerCase());
466
+ }
467
+ }
468
+ }
469
+ function planSchemas(plan) {
470
+ const schemas = new Set;
471
+ for (const step of plan.steps) {
472
+ const target = step.kind === "grant" ? step.name.split(":")[0] : step.name;
473
+ schemas.add(splitQualifiedName(target)[0]);
474
+ }
475
+ return schemas.size > 0 ? [...schemas].sort() : ["public"];
476
+ }
477
+ async function applyModulePlan(executor, plan) {
478
+ const errorRisks = plan.steps.flatMap((step) => step.risk.filter((risk) => risk.severity === "error").map((risk) => `${step.name}: ${risk.code} ${risk.message}`));
479
+ if (errorRisks.length > 0) {
480
+ throw new Error(`plan 含 error 级风险,拒绝执行: ${errorRisks.join("; ")}`);
481
+ }
482
+ const result = {
483
+ module: plan.module,
484
+ applied: [],
485
+ skipped: [],
486
+ verified: []
487
+ };
488
+ await executor.query(LEDGER_SCHEMA_SQL);
489
+ await executor.query(LEDGER_TABLE_SQL);
490
+ await executor.query(LOCK_SQL);
491
+ try {
492
+ const applied = [];
493
+ const skipped = [];
494
+ const failed = await runInTransaction(executor, async (tx) => {
495
+ for (const step of plan.steps) {
496
+ const identity = `${step.kind}:${step.name}`;
497
+ const rows = await tx.query(LEDGER_READ_SQL, [identity]);
498
+ if (rows[0]?.sha256 === step.sha256) {
499
+ skipped.push(step.name);
500
+ continue;
501
+ }
502
+ try {
503
+ await tx.query(step.sql);
504
+ await tx.query(LEDGER_UPSERT_SQL, [identity, plan.module, step.sha256]);
505
+ } catch (error) {
506
+ throw new StepFailure(step.name, error);
507
+ }
508
+ applied.push(step.name);
509
+ }
510
+ });
511
+ if (failed) {
512
+ result.failed = failed;
513
+ return result;
514
+ }
515
+ result.applied = applied;
516
+ result.skipped = skipped;
517
+ } finally {
518
+ await executor.query(UNLOCK_SQL);
519
+ }
520
+ const catalog = await readCatalog(executor, planSchemas(plan));
521
+ for (const step of plan.steps) {
522
+ if (existsInCatalog(step, catalog)) {
523
+ result.verified.push(step.name);
524
+ } else {
525
+ result.failed = {
526
+ step: step.name,
527
+ error: `应用后 catalog 中未找到 ${step.kind} ${step.name}`
528
+ };
529
+ break;
530
+ }
531
+ }
532
+ return result;
533
+ }
299
534
  // src/manifest.ts
300
535
  function buildDatabaseManifest(modules) {
301
536
  return {
@@ -400,13 +635,65 @@ function explainObject(manifest, name) {
400
635
  }
401
636
  return `未找到对象: ${name}`;
402
637
  }
638
+ // src/access.ts
639
+ class DatabaseAccessError extends Error {
640
+ expose = true;
641
+ code;
642
+ status;
643
+ constructor(code, status, message) {
644
+ super(message);
645
+ this.name = "DatabaseAccessError";
646
+ this.code = code;
647
+ this.status = status;
648
+ }
649
+ }
650
+ function createDatabaseAccessBoundary(options) {
651
+ const allowedReasons = new Set(options.allowedServiceReasons ?? []);
652
+ let serviceClient;
653
+ return {
654
+ async forUser(identity) {
655
+ if (!identity.subject) {
656
+ throw new DatabaseAccessError("DATABASE_IDENTITY_REQUIRED", 401, "A verified user identity is required for request database access");
657
+ }
658
+ if (!identity.accessToken) {
659
+ throw new DatabaseAccessError("DATABASE_ACCESS_TOKEN_REQUIRED", 401, "A verified user access token is required for request database access");
660
+ }
661
+ return options.createUserClient({
662
+ subject: identity.subject,
663
+ accessToken: identity.accessToken
664
+ });
665
+ },
666
+ async forService(reason) {
667
+ const normalizedReason = reason.trim();
668
+ if (!normalizedReason) {
669
+ throw new DatabaseAccessError("SERVICE_ROLE_REASON_REQUIRED", 403, "Service-role database access requires a non-empty reason");
670
+ }
671
+ if (!allowedReasons.has(normalizedReason)) {
672
+ throw new DatabaseAccessError("SERVICE_ROLE_REASON_NOT_ALLOWED", 403, `Service-role database access reason "${normalizedReason}" is not allowed`);
673
+ }
674
+ if (!options.createServiceClient) {
675
+ throw new DatabaseAccessError("SERVICE_ROLE_UNAVAILABLE", 501, "Service-role database access is not configured");
676
+ }
677
+ serviceClient ??= Promise.resolve(options.createServiceClient()).catch((error) => {
678
+ serviceClient = undefined;
679
+ throw error;
680
+ });
681
+ return serviceClient;
682
+ }
683
+ };
684
+ }
403
685
  export {
686
+ DatabaseAccessError,
687
+ applyModulePlan,
404
688
  buildDatabaseManifest,
689
+ createDatabaseAccessBoundary,
405
690
  defineDatabaseModule,
406
691
  explainObject,
407
692
  extractSearchPath,
408
693
  lintModule,
409
694
  lintSql,
695
+ planModule,
410
696
  readCatalog,
411
- reconcileModule
697
+ reconcileModule,
698
+ splitQualifiedName
412
699
  };
package/dist/plan.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Plan:把模块声明的可重复 SQL 对象(函数/策略/触发器/授权)编译为有序执行计划。
3
+ * 边界:只管可重复对象,不做表结构 migration —— 表结构仍走前向 migration。
4
+ */
5
+ import type { DatabaseModule } from './module.js';
6
+ export interface PlanStep {
7
+ kind: 'function' | 'policy' | 'trigger' | 'grant';
8
+ /** 对象标识:函数为 schema 限定名;策略/触发器为 table.name;授权为 object:privilege:role */
9
+ name: string;
10
+ /** 模块相对路径 */
11
+ source: string;
12
+ /** 源文件内容哈希 */
13
+ sha256: string;
14
+ sql: string;
15
+ risk: Array<{
16
+ severity: 'error' | 'warn';
17
+ code: string;
18
+ message: string;
19
+ }>;
20
+ }
21
+ export interface ModulePlan {
22
+ version: 1;
23
+ module: string;
24
+ createdAt: string;
25
+ /** 依赖序:function -> policy -> trigger -> grant */
26
+ steps: PlanStep[];
27
+ /** 全部 step sha256 的组合哈希 */
28
+ digest: string;
29
+ }
30
+ export declare function planModule(module: DatabaseModule, readFile: (path: string) => Promise<string>): Promise<ModulePlan>;
@@ -16,4 +16,6 @@ export interface ReconcileReport {
16
16
  /** 无 error 级问题 */
17
17
  ok: boolean;
18
18
  }
19
+ /** 'public.cases' → ['public', 'cases'];无 schema 前缀时默认 public */
20
+ export declare function splitQualifiedName(name: string): [string, string];
19
21
  export declare function reconcileModule(module: DatabaseModule, catalog: DatabaseCatalog): ReconcileReport;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supacloud/db",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Database governance layer for SupaCloud: RLS policies, RPC functions and grants as first-class resources, with manifest/catalog reconciliation",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",