@facilis.dev/db 0.0.0-stage → 0.1.1

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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -2
  3. package/dist/client.d.ts +78 -0
  4. package/dist/client.js +208 -0
  5. package/dist/files.d.ts +268 -0
  6. package/dist/files.js +88 -0
  7. package/dist/index.d.ts +23 -0
  8. package/dist/index.js +23 -0
  9. package/dist/jobs.d.ts +255 -0
  10. package/dist/jobs.js +64 -0
  11. package/dist/live-schema.d.ts +39 -0
  12. package/dist/live-schema.js +97 -0
  13. package/dist/migrator.d.ts +20 -0
  14. package/dist/migrator.js +66 -0
  15. package/dist/policy.d.ts +50 -0
  16. package/dist/policy.js +111 -0
  17. package/dist/query-console.d.ts +44 -0
  18. package/dist/query-console.js +173 -0
  19. package/dist/read-only-sql.d.ts +13 -0
  20. package/dist/read-only-sql.js +145 -0
  21. package/dist/rls.d.ts +37 -0
  22. package/dist/rls.js +90 -0
  23. package/dist/schema-digest.d.ts +97 -0
  24. package/dist/schema-digest.js +178 -0
  25. package/dist/schema-drift.d.ts +43 -0
  26. package/dist/schema-drift.js +176 -0
  27. package/dist/snapshot-readers.d.ts +3 -0
  28. package/dist/snapshot-readers.js +182 -0
  29. package/dist/snapshot.d.ts +92 -0
  30. package/dist/snapshot.js +21 -0
  31. package/dist/sudo-audit.d.ts +60 -0
  32. package/dist/sudo-audit.js +45 -0
  33. package/dist/sudo.d.ts +54 -0
  34. package/dist/sudo.js +137 -0
  35. package/dist/table-guard.d.ts +37 -0
  36. package/dist/table-guard.js +228 -0
  37. package/dist/tasks.d.ts +312 -0
  38. package/dist/tasks.js +52 -0
  39. package/dist/tenant-column.d.ts +21 -0
  40. package/dist/tenant-column.js +27 -0
  41. package/dist/views.d.ts +92 -0
  42. package/dist/views.js +186 -0
  43. package/dist/workflows.d.ts +289 -0
  44. package/dist/workflows.js +55 -0
  45. package/package.json +43 -4
  46. package/src/client.ts +291 -0
  47. package/src/files.ts +124 -0
  48. package/src/index.ts +174 -0
  49. package/src/jobs.ts +76 -0
  50. package/src/live-schema.ts +145 -0
  51. package/src/migrator.ts +92 -0
  52. package/src/policy.ts +174 -0
  53. package/src/query-console.ts +215 -0
  54. package/src/read-only-sql.ts +156 -0
  55. package/src/rls.ts +109 -0
  56. package/src/schema-digest.ts +240 -0
  57. package/src/schema-drift.ts +234 -0
  58. package/src/snapshot-readers.ts +212 -0
  59. package/src/snapshot.ts +122 -0
  60. package/src/sudo-audit.ts +91 -0
  61. package/src/sudo.ts +200 -0
  62. package/src/table-guard.ts +296 -0
  63. package/src/tasks.ts +79 -0
  64. package/src/tenant-column.ts +29 -0
  65. package/src/views.ts +291 -0
  66. package/src/workflows.ts +76 -0
package/src/sudo.ts ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Audited sudo (D-099): run a block of code in the current transaction as the framework's
3
+ * `facilis_system` Postgres role.
4
+ *
5
+ * The system role is the one named exception to SPEC §8.4.5's "no Postgres roles" rule: it
6
+ * is NOLOGIN, NOSUPERUSER with BYPASSRLS and DML grants only (no DDL), it is never assigned
7
+ * to users and never referenced by policies, and it is reachable only through this module.
8
+ * Every escalation is recorded on the append-only audit sink first (fail closed), then in
9
+ * the SAVEPOINT, so a crash between entries leaves a visible trace instead of a silent one.
10
+ */
11
+
12
+ import { sql } from "drizzle-orm";
13
+
14
+ import type { FacilisDb } from "./client.ts";
15
+ import { APP_ROLE, type PolicyScopedDatabase } from "./policy.ts";
16
+ import { newSudoId, writeSudoAudit } from "./sudo-audit.ts";
17
+ import { guardDatabase, TableAccessError } from "./table-guard.ts";
18
+
19
+ /** The elevated Postgres role; provisioned by `ensureSystemRole` (dev) or the deploy step. */
20
+ export const SYSTEM_ROLE = "facilis_system";
21
+
22
+ /** GUC carrying the audit call id while a sudo block runs. Not a provenance column. */
23
+ export const SUDO_ID_GUC = "app.sudo_id";
24
+
25
+ /**
26
+ * Creates the system role and grants it DML on the public schema, plus membership to
27
+ * facilis_app (production connects as the app role and must be able to SET ROLE). Idempotent
28
+ * and re-asserting, like ensureAppRole: an older dataDir must not keep weaker attributes.
29
+ */
30
+ export async function ensureSystemRole(db: FacilisDb): Promise<void> {
31
+ const existing = await db.db.execute(sql`select 1 from pg_roles where rolname = ${SYSTEM_ROLE}`);
32
+ if (existing.rows.length === 0) {
33
+ await db.db.execute(sql.raw(`CREATE ROLE ${SYSTEM_ROLE} NOLOGIN NOSUPERUSER BYPASSRLS`));
34
+ }
35
+ // Re-assert attributes in case an older dev dataDir created the role differently.
36
+ await db.db.execute(sql.raw(`ALTER ROLE ${SYSTEM_ROLE} NOSUPERUSER BYPASSRLS NOLOGIN`));
37
+ await db.db.execute(sql.raw(`GRANT USAGE ON SCHEMA public TO ${SYSTEM_ROLE}`));
38
+ await db.db.execute(
39
+ sql.raw(
40
+ `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${SYSTEM_ROLE}`,
41
+ ),
42
+ );
43
+ await db.db.execute(
44
+ sql.raw(
45
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${SYSTEM_ROLE}`,
46
+ ),
47
+ );
48
+ await db.db.execute(
49
+ sql.raw(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${SYSTEM_ROLE}`),
50
+ );
51
+ await db.db.execute(
52
+ sql.raw(
53
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${SYSTEM_ROLE}`,
54
+ ),
55
+ );
56
+ // The runtime may connect as facilis_app: membership is what lets it SET ROLE.
57
+ await db.db.execute(sql.raw(`GRANT ${SYSTEM_ROLE} TO ${APP_ROLE}`));
58
+ }
59
+
60
+ export interface RunWithSystemRoleOptions {
61
+ /** Audit call id, exposed as app.sudo_id for correlation inside the block. */
62
+ readonly sudoId: string;
63
+ }
64
+
65
+ /**
66
+ * Runs `fn` in a SAVEPOINT of the current transaction as facilis_system.
67
+ *
68
+ * On success the role is restored to facilis_app explicitly: RESET ROLE would return to the
69
+ * connecting session user (a superuser in dev) and silently widen the rest of the request.
70
+ * On failure the savepoint rollback restores both the role and the GUCs.
71
+ */
72
+ export async function runWithSystemRole<T>(
73
+ db: PolicyScopedDatabase,
74
+ options: RunWithSystemRoleOptions,
75
+ fn: (systemDb: PolicyScopedDatabase) => Promise<T>,
76
+ ): Promise<T> {
77
+ return db.transaction(async (tx) => {
78
+ await tx.execute(sql.raw(`SET LOCAL ROLE ${SYSTEM_ROLE}`));
79
+ await tx.execute(sql`select set_config('app.sudo_id', ${options.sudoId}, true)`);
80
+ const result = await fn(tx);
81
+ await tx.execute(sql.raw(`SET LOCAL ROLE ${APP_ROLE}`));
82
+ await tx.execute(sql`select set_config('app.sudo_id', '', true)`);
83
+ return result;
84
+ });
85
+ }
86
+
87
+ export interface RunAsSystemOptions {
88
+ /** Audit actor label for a job/CLI caller, e.g. "system:nightly-reindex". */
89
+ readonly actor: `system:${string}`;
90
+ readonly operation: string;
91
+ readonly reason: string;
92
+ /**
93
+ * The job's declared tables and views (SQL names), possibly empty. The elevated client is
94
+ * guarded by this declaration, not by whatever the system role happens to reach (D-157).
95
+ */
96
+ readonly dbTables: readonly string[];
97
+ /** Explicit exception for raw SQL inside the job (D-157); default false. */
98
+ readonly rawSql?: boolean;
99
+ /** Correlation id when the job was triggered by a request. */
100
+ readonly requestId?: string;
101
+ }
102
+
103
+ /**
104
+ * Runs `fn` in its own transaction as facilis_system, audited like `ctx.sudo`, and returns
105
+ * its result. This is the job/CLI counterpart of the route context's sudo.
106
+ */
107
+ export async function runAsSystem<T>(
108
+ db: FacilisDb,
109
+ options: RunAsSystemOptions,
110
+ fn: (systemDb: PolicyScopedDatabase) => Promise<T>,
111
+ ): Promise<T> {
112
+ assertSystemRunOptions(options);
113
+ const id = newSudoId();
114
+ const startedAt = Date.now();
115
+ // Relations the guarded client actually reached; only declared ones can get this far.
116
+ const observed = new Set<string>();
117
+ const base = {
118
+ id,
119
+ actor: options.actor,
120
+ roles: [] as readonly string[],
121
+ operation: options.operation,
122
+ reason: options.reason,
123
+ tables: options.dbTables,
124
+ ...(options.requestId === undefined ? {} : { requestId: options.requestId }),
125
+ };
126
+ writeSudoAudit({ ...base, event: "started", at: new Date().toISOString() });
127
+
128
+ try {
129
+ const result = await db.db.transaction((tx) =>
130
+ runWithSystemRole(tx, { sudoId: id }, (systemDb) =>
131
+ fn(
132
+ guardDatabase(systemDb, {
133
+ declared: options.dbTables,
134
+ allowRawSql: options.rawSql === true,
135
+ onRelation: (relation) => observed.add(relation),
136
+ }),
137
+ ),
138
+ ),
139
+ );
140
+ writeSudoAudit({
141
+ ...base,
142
+ event: "succeeded",
143
+ outcome: "succeeded",
144
+ observedTables: [...observed],
145
+ at: new Date().toISOString(),
146
+ durationMs: Date.now() - startedAt,
147
+ });
148
+ return result;
149
+ } catch (error) {
150
+ // Canonical code only: the internal message may name the relation, which is not audited.
151
+ writeSudoAudit({
152
+ ...base,
153
+ event: "failed",
154
+ outcome: "failed",
155
+ at: new Date().toISOString(),
156
+ errorCode: error instanceof TableAccessError ? error.code : "handler_failed",
157
+ durationMs: Date.now() - startedAt,
158
+ });
159
+ throw error;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Rejects a job call that could not produce a meaningful audit entry.
165
+ *
166
+ * An empty `dbTables` list is deliberately allowed here (D-107): a job may declare that it
167
+ * touches no relation, and it is the only caller permitted to do so — `ctx.sudo`/`ctx.system`
168
+ * still reject an empty subset in @facilis.dev/core. The declaration is enforced by the guard.
169
+ */
170
+ function assertSystemRunOptions(options: RunAsSystemOptions): void {
171
+ if (typeof options.actor !== "string" || !options.actor.startsWith("system:")) {
172
+ throw new Error('sudo_invalid_actor: actor must be "system:<job>" for a system run.');
173
+ }
174
+ if (typeof options.operation !== "string" || options.operation.trim().length === 0) {
175
+ throw new Error("sudo_invalid_operation: operation must be a non-empty string.");
176
+ }
177
+ if (typeof options.reason !== "string" || options.reason.trim().length === 0) {
178
+ throw new Error("sudo_invalid_reason: reason must be a non-empty string.");
179
+ }
180
+ if (!isStringList(options.dbTables)) {
181
+ throw new Error(
182
+ "sudo_invalid_dbTables: dbTables must be an array of non-empty strings (possibly empty).",
183
+ );
184
+ }
185
+ if (options.rawSql !== undefined && typeof options.rawSql !== "boolean") {
186
+ throw new Error("sudo_invalid_rawSql: rawSql must be a boolean when present.");
187
+ }
188
+ }
189
+
190
+ /**
191
+ * An array of non-empty strings, possibly empty: a job that declares no relation still
192
+ * produces a valid audit entry (D-107). The empty case is left to the caller's policy, so
193
+ * runAsSystem can accept it while the route-scoped sudo keeps its non-empty subset rule.
194
+ */
195
+ function isStringList(value: unknown): value is readonly string[] {
196
+ return (
197
+ Array.isArray(value) &&
198
+ value.every((entry): entry is string => typeof entry === "string" && entry.length > 0)
199
+ );
200
+ }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Runtime enforcement of a route's `dbTables` declaration (D-157).
3
+ *
4
+ * The static analyzer can see a route's queries, but only the wrapper sees every relation
5
+ * the handler actually touches. `guardDatabase` wraps the policy-scoped client in a Proxy:
6
+ * a relation is checked against the declaration before its query builder runs, so an
7
+ * incomplete declaration fails closed instead of querying and auditing whatever it likes.
8
+ *
9
+ * The wrapper never executes a query itself: it forwards fluent calls to the real Drizzle
10
+ * builder and only intercepts the next relation-bearing call. Query builders are thenable,
11
+ * so `then`/`catch`/`finally` are bound to the unwrapped target and awaiting a guarded
12
+ * builder still runs the original query exactly once.
13
+ */
14
+
15
+ import { Column, Table, ViewBaseConfig, getTableName, getViewName, is, isView } from "drizzle-orm";
16
+
17
+ import type { PolicyScopedDatabase } from "./policy.ts";
18
+
19
+ /** Canonical failure codes of the guard; both are 500s in the core error registry. */
20
+ export type TableAccessErrorCode = "table_not_declared" | "raw_sql_not_allowed";
21
+
22
+ /** Thrown before execution when a route reaches outside its declaration. */
23
+ export class TableAccessError extends Error {
24
+ readonly code: TableAccessErrorCode;
25
+ /** Server-side context only: the route wrapper never puts it in the response body. */
26
+ readonly relation?: string;
27
+
28
+ constructor(code: TableAccessErrorCode, relation?: string) {
29
+ super(
30
+ code === "raw_sql_not_allowed"
31
+ ? "db.execute is not allowed: the route must declare rawSql: true."
32
+ : `Relation "${relation ?? "unknown"}" is not declared in the route's dbTables.`,
33
+ );
34
+ this.name = "TableAccessError";
35
+ this.code = code;
36
+ if (relation !== undefined) this.relation = relation;
37
+ }
38
+ }
39
+
40
+ /** What a route exposes to the guard: its declaration plus the audited-observation hook. */
41
+ export interface TableGuardOptions {
42
+ /** The route's `dbTables` (tables and views, SQL names). */
43
+ readonly declared: readonly string[];
44
+ /** `rawSql: true` on the route; only then may `db.execute` run. */
45
+ readonly allowRawSql?: boolean;
46
+ /** Called for every declared relation the handler actually reaches (sudo audit, D-157). */
47
+ readonly onRelation?: (relation: string) => void;
48
+ }
49
+
50
+ /** Calls that take a relation and must be checked before the builder runs. */
51
+ const RELATION_METHODS: ReadonlySet<string> = new Set([
52
+ "select",
53
+ "selectDistinct",
54
+ "insert",
55
+ "update",
56
+ "delete",
57
+ "from",
58
+ "join",
59
+ "leftJoin",
60
+ "rightJoin",
61
+ "innerJoin",
62
+ "fullJoin",
63
+ "into",
64
+ ]);
65
+
66
+ /** Query-builder promise methods: bound to the real builder, never wrapped as fluent calls. */
67
+ const THEN_METHODS: ReadonlySet<string> = new Set(["then", "catch", "finally"]);
68
+
69
+ /** Runtime table symbols; Drizzle 0.45 omits its `Table.Symbol` from the declarations. */
70
+ const TABLE_SYMBOLS = (
71
+ Table as unknown as {
72
+ readonly Symbol: {
73
+ readonly IsAlias: symbol;
74
+ readonly OriginalName: symbol;
75
+ };
76
+ }
77
+ ).Symbol;
78
+
79
+ type SurfaceKind = "db" | "query" | "builder" | "relation";
80
+
81
+ interface GuardState {
82
+ readonly declared: ReadonlySet<string>;
83
+ readonly allowRawSql: boolean;
84
+ readonly onRelation: ((relation: string) => void) | undefined;
85
+ readonly proxies: WeakMap<object, object>;
86
+ readonly targets: WeakMap<object, object>;
87
+ /** Set when an allowed `db.execute` runs; raw SQL has no inspectable relation names. */
88
+ rawSqlUsed: boolean;
89
+ }
90
+
91
+ /**
92
+ * Wraps a policy-scoped client so every relation is checked against `declared` before the
93
+ * query executes. The returned value is the same Drizzle transaction surface.
94
+ */
95
+ export function guardDatabase(
96
+ db: PolicyScopedDatabase,
97
+ options: TableGuardOptions,
98
+ ): PolicyScopedDatabase {
99
+ const state: GuardState = {
100
+ declared: new Set(options.declared),
101
+ allowRawSql: options.allowRawSql === true,
102
+ onRelation: options.onRelation,
103
+ proxies: new WeakMap(),
104
+ targets: new WeakMap(),
105
+ rawSqlUsed: false,
106
+ };
107
+ return wrapSurface(db as object, "db", state) as PolicyScopedDatabase;
108
+ }
109
+
110
+ function wrapSurface(target: object, kind: SurfaceKind, state: GuardState): object {
111
+ const wrapped = state.proxies.get(target);
112
+ if (wrapped !== undefined) return wrapped;
113
+ const proxy = new Proxy(target, createHandler(state, kind));
114
+ state.proxies.set(target, proxy);
115
+ state.targets.set(proxy, target);
116
+ return proxy;
117
+ }
118
+
119
+ function createHandler(state: GuardState, kind: SurfaceKind): ProxyHandler<object> {
120
+ return {
121
+ get(target, property) {
122
+ const value: unknown = Reflect.get(target, property);
123
+
124
+ // Awaiting a guarded builder must call the real builder's then/catch/finally.
125
+ if (typeof property === "string" && THEN_METHODS.has(property)) {
126
+ return typeof value === "function"
127
+ ? (value as (...args: unknown[]) => unknown).bind(target)
128
+ : value;
129
+ }
130
+
131
+ // The relational API is keyed by relation: validate before handing out the builder.
132
+ if (kind === "db" && property === "query") {
133
+ return isObject(value) ? wrapSurface(value, "query", state) : value;
134
+ }
135
+ if (kind === "query" && typeof property === "string") {
136
+ if (!isObject(value)) return value;
137
+ const relation = relationFromQueryEntry(value) ?? property;
138
+ observe(relation, state);
139
+ return wrapSurface(value, "relation", state);
140
+ }
141
+
142
+ // The raw driver escape hatches are exactly the SQL the declaration cannot describe.
143
+ if (
144
+ kind === "db" &&
145
+ !state.allowRawSql &&
146
+ (property === "$client" || property === "session")
147
+ ) {
148
+ throw new TableAccessError("raw_sql_not_allowed");
149
+ }
150
+
151
+ if (typeof value !== "function") return value;
152
+ return createMethod(target, property, value as (...args: unknown[]) => unknown, kind, state);
153
+ },
154
+ };
155
+ }
156
+
157
+ function createMethod(
158
+ target: object,
159
+ property: string | symbol,
160
+ method: (...args: unknown[]) => unknown,
161
+ kind: SurfaceKind,
162
+ state: GuardState,
163
+ ): (...args: unknown[]) => unknown {
164
+ return (...args: unknown[]): unknown => {
165
+ if (kind === "db" && property === "execute") {
166
+ if (!state.allowRawSql) throw new TableAccessError("raw_sql_not_allowed");
167
+ state.rawSqlUsed = true;
168
+ }
169
+ if (kind === "db" && property === "transaction") {
170
+ return runTransaction(target, method, args, state);
171
+ }
172
+ if (typeof property === "string" && RELATION_METHODS.has(property)) {
173
+ for (const arg of args) observeArgument(arg, state);
174
+ }
175
+ const result = Reflect.apply(method, target, unwrapArgs(args, state));
176
+ return wrapResult(result, target, state);
177
+ };
178
+ }
179
+
180
+ /** Runs the callback with a guarded nested transaction; the query itself still executes once. */
181
+ function runTransaction(
182
+ target: object,
183
+ method: (...args: unknown[]) => unknown,
184
+ args: unknown[],
185
+ state: GuardState,
186
+ ): unknown {
187
+ const [callback, ...rest] = args;
188
+ if (typeof callback !== "function") {
189
+ return Reflect.apply(method, target, unwrapArgs(args, state));
190
+ }
191
+ const wrapped = (tx: unknown, ...inner: unknown[]): unknown =>
192
+ (callback as (...callbackArgs: unknown[]) => unknown)(
193
+ isObject(tx) ? wrapSurface(tx, "db", state) : tx,
194
+ ...inner.map((value) => unwrap(value, state)),
195
+ );
196
+ return Reflect.apply(method, target, [wrapped, ...unwrapArgs(rest, state)]);
197
+ }
198
+
199
+ function wrapResult(result: unknown, target: object, state: GuardState): unknown {
200
+ // Drizzle returns `this` from fluent methods: reuse the existing proxy, do not nest.
201
+ if (result === target) return state.proxies.get(target);
202
+ if (isObject(result) && isBuilderLike(result)) return wrapSurface(result, "builder", state);
203
+ return result;
204
+ }
205
+
206
+ /** Thenable query builders plus the pre-`from` select builder, which is not thenable yet. */
207
+ function isBuilderLike(value: object): boolean {
208
+ if (isThenable(value)) return true;
209
+ for (const method of RELATION_METHODS) {
210
+ if (typeof (value as Record<string, unknown>)[method] === "function") return true;
211
+ }
212
+ return false;
213
+ }
214
+
215
+ function observeArgument(value: unknown, state: GuardState, seen: Set<object> = new Set()): void {
216
+ if (!isObject(value) || seen.has(value)) return;
217
+ seen.add(value);
218
+
219
+ const relation = relationNameOf(value);
220
+ if (relation !== null) {
221
+ observe(relation, state);
222
+ return;
223
+ }
224
+ if (Array.isArray(value)) {
225
+ for (const item of value) observeArgument(item, state, seen);
226
+ return;
227
+ }
228
+ if (is(value, Column)) {
229
+ observeArgument(value.table, state, seen);
230
+ return;
231
+ }
232
+ if (isPlainObject(value)) {
233
+ for (const item of Object.values(value)) observeArgument(item, state, seen);
234
+ }
235
+ }
236
+
237
+ function observe(relation: string, state: GuardState): void {
238
+ if (!state.declared.has(relation)) throw new TableAccessError("table_not_declared", relation);
239
+ state.onRelation?.(relation);
240
+ }
241
+
242
+ /**
243
+ * SQL name of a relation: aliased tables resolve to their original name, and a view is its
244
+ * own relation (D-097/D-098), never its base tables.
245
+ */
246
+ function relationNameOf(value: object): string | null {
247
+ if (is(value, Table)) {
248
+ // Drizzle 0.45 exposes the symbol table at runtime but not in its type declarations.
249
+ const symbols = (Table as unknown as { readonly Symbol: typeof TABLE_SYMBOLS }).Symbol;
250
+ const table = value as unknown as Record<symbol, unknown>;
251
+ if (table[symbols.IsAlias] === true) {
252
+ const original = table[symbols.OriginalName];
253
+ if (typeof original === "string" && original.length > 0) return original;
254
+ }
255
+ const name = getTableName(value);
256
+ return typeof name === "string" && name.length > 0 ? name : null;
257
+ }
258
+ if (isView(value)) {
259
+ const config = (value as unknown as Record<symbol, unknown>)[ViewBaseConfig];
260
+ if (isObject(config)) {
261
+ const record = config as Record<string, unknown>;
262
+ const name =
263
+ record["isAlias"] === true ? (record["originalName"] ?? record["name"]) : record["name"];
264
+ if (typeof name === "string" && name.length > 0) return name;
265
+ }
266
+ const name = getViewName(value);
267
+ return typeof name === "string" && name.length > 0 ? name : null;
268
+ }
269
+ return null;
270
+ }
271
+
272
+ function relationFromQueryEntry(value: object): string | null {
273
+ const table = (value as { readonly table?: unknown }).table;
274
+ return isObject(table) ? relationNameOf(table) : null;
275
+ }
276
+
277
+ function unwrap(value: unknown, state: GuardState): unknown {
278
+ return isObject(value) ? (state.targets.get(value) ?? value) : value;
279
+ }
280
+
281
+ function unwrapArgs(args: unknown[], state: GuardState): unknown[] {
282
+ return args.map((value) => unwrap(value, state));
283
+ }
284
+
285
+ function isThenable(value: object): boolean {
286
+ return typeof (value as { readonly then?: unknown }).then === "function";
287
+ }
288
+
289
+ function isObject(value: unknown): value is object {
290
+ return typeof value === "object" && value !== null;
291
+ }
292
+
293
+ function isPlainObject(value: object): boolean {
294
+ const prototype = Object.getPrototypeOf(value);
295
+ return prototype === Object.prototype || prototype === null;
296
+ }
package/src/tasks.ts ADDED
@@ -0,0 +1,79 @@
1
+ // The manual-task table (D-135, docs/WORKFLOWS.md §5).
2
+ //
3
+ // A task is framework state, not application data: tenant-scoped so the structural invariant
4
+ // holds (pgPolicy + ENABLE ROW LEVEL SECURITY), re-exported by the project's db/schema.ts, and
5
+ // DDL-owned by a Drizzle migration like the job queue (D-107).
6
+ import { sql } from "drizzle-orm";
7
+ import {
8
+ index,
9
+ integer,
10
+ jsonb,
11
+ pgPolicy,
12
+ pgTable,
13
+ text,
14
+ timestamp,
15
+ uuid,
16
+ } from "drizzle-orm/pg-core";
17
+
18
+ import { tenantIsolationPolicy } from "./rls.ts";
19
+ import { tenantColumn } from "./tenant-column.ts";
20
+
21
+ /** Every facilis_tasks.status value, in lifecycle order. */
22
+ export const FACILIS_TASK_STATUSES = ["pending", "decided", "expired", "cancelled"] as const;
23
+
24
+ export type FacilisTaskStatus = (typeof FACILIS_TASK_STATUSES)[number];
25
+
26
+ /** What the task asks for; approvals are the only kind the v1 slice ships. */
27
+ export const FACILIS_TASK_KINDS = ["approval"] as const;
28
+
29
+ export type FacilisTaskKind = (typeof FACILIS_TASK_KINDS)[number];
30
+
31
+ /** How a decided task closes. */
32
+ export const FACILIS_TASK_DECISIONS = ["approve", "reject"] as const;
33
+
34
+ export type FacilisTaskDecision = (typeof FACILIS_TASK_DECISIONS)[number];
35
+
36
+ /**
37
+ * One manual task. Statuses stay text (a new state is a code change, not a migration) and the
38
+ * workflow columns are nullable so a task can exist standalone before the workflow layer lands
39
+ * (D-135 W1).
40
+ */
41
+ export const facilisTasks = pgTable(
42
+ "facilis_tasks",
43
+ {
44
+ id: uuid("id").primaryKey().defaultRandom(),
45
+ workflowId: uuid("workflow_id"),
46
+ stepIndex: integer("step_index"),
47
+ kind: text("kind").notNull(),
48
+ status: text("status").notNull(),
49
+ decision: text("decision"),
50
+ titleKey: text("title_key").notNull(),
51
+ titleParams: jsonb("title_params").notNull().default({}),
52
+ payload: jsonb("payload"),
53
+ requestedBy: text("requested_by"),
54
+ assignedPermission: text("assigned_permission"),
55
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
56
+ decidedBy: text("decided_by"),
57
+ decidedAt: timestamp("decided_at", { withTimezone: true }),
58
+ comment: text("comment"),
59
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
60
+ tenantId: tenantColumn(),
61
+ },
62
+ (table) => [
63
+ tenantIsolationPolicy(),
64
+ // A RESTRICTIVE policy only narrows what a permissive policy already allows; with no
65
+ // permissive policy Postgres denies every row (D-008). The queue's reads/writes run as
66
+ // facilis_app, so the scoped tenant needs this gate — exactly like facilis_jobs.
67
+ pgPolicy("facilis_tasks_tenant_access", {
68
+ for: "all",
69
+ using: sql`nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
70
+ withCheck: sql`nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
71
+ }),
72
+ index("facilis_tasks_status_created_idx").on(table.status, table.createdAt),
73
+ index("facilis_tasks_workflow_idx").on(table.workflowId),
74
+ index("facilis_tasks_assigned_idx").on(table.assignedPermission, table.status),
75
+ ],
76
+ ).enableRLS();
77
+
78
+ export type FacilisTaskRow = typeof facilisTasks.$inferSelect;
79
+ export type NewFacilisTaskRow = typeof facilisTasks.$inferInsert;
@@ -0,0 +1,29 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { uuid } from "drizzle-orm/pg-core";
3
+
4
+ /**
5
+ * SQL expression that fills a tenant column from the policy-scope GUC.
6
+ *
7
+ * It reads the same `app.tenant_id` every RLS policy reads, transaction-locally set by
8
+ * `runWithPolicyScope` before the handler runs (contracts §4.2). The `nullif` guard is the
9
+ * same one the policies use: after a `RESET` a GUC reads as `''`, and `''::uuid` raises.
10
+ */
11
+ export function tenantDefaultSql() {
12
+ return sql`nullif(current_setting('app.tenant_id', true), '')::uuid`;
13
+ }
14
+
15
+ /**
16
+ * A `tenant_id` column that takes its value from the policy scope (D-040).
17
+ *
18
+ * ```ts
19
+ * export const todos = pgTable("todos", { tenantId: tenantColumn(), ... }, ...).enableRLS();
20
+ * ```
21
+ *
22
+ * The default is the DB's, not the app's: an insert never names the tenant (raw SQL included),
23
+ * and it cannot name a different one — the RESTRICTIVE tenant policy still checks the written
24
+ * row against the same GUC. It also makes the column optional in Drizzle's insert type, so app
25
+ * code stops passing `tenantId: auth.tenantId` and the compiler stops asking for it.
26
+ */
27
+ export function tenantColumn(name = "tenant_id") {
28
+ return uuid(name).notNull().default(tenantDefaultSql());
29
+ }