@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/client.ts ADDED
@@ -0,0 +1,291 @@
1
+ import { createRequire } from "node:module";
2
+ import { join } from "node:path";
3
+
4
+ import type { PGlite as PgliteClient } from "@electric-sql/pglite";
5
+ import { drizzle } from "drizzle-orm/pglite";
6
+ import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
7
+
8
+ /**
9
+ * PGlite must come from its CJS build (vendor/vivari/AGENTS.md, "In-VM databases"):
10
+ * the ESM build locates `pglite.wasm` and `pglite.data` through `import.meta.url`, and
11
+ * that URL is only as good as the module id the bundler handed the module. A relative id
12
+ * becomes `file://home/…` — the first segment parses as a URL *host* — so the resolved
13
+ * path loses `/home` (`/user/projects/…`) and the DB cannot boot with ENOENT. The CJS
14
+ * build resolves the same artifacts from `__filename`, a real VFS path, through
15
+ * `new URL("file:" + __filename)` = `file:/home/…`; the pinned runtime converts that back
16
+ * to the intended path even where the browser parses it as hostful, i.e. on Chrome/Windows
17
+ * (fork `e654919`, D-039 and D-041).
18
+ *
19
+ * Two resolution bases: this module's own URL (works in Node and under Vitest), then the
20
+ * project root, which is the cwd the in-VM Nitro dev server runs in.
21
+ */
22
+ function loadPglite(): typeof import("@electric-sql/pglite") {
23
+ let lastError: unknown;
24
+ for (const base of [import.meta.url, join(process.cwd(), "package.json")]) {
25
+ try {
26
+ return createRequire(base)("@electric-sql/pglite") as typeof import("@electric-sql/pglite");
27
+ } catch (error) {
28
+ lastError = error;
29
+ }
30
+ }
31
+ throw new Error("facilis: cannot load @electric-sql/pglite — is it installed?", {
32
+ cause: lastError,
33
+ });
34
+ }
35
+
36
+ const { PGlite } = loadPglite();
37
+
38
+ /** Drivers the public API promises. Both are implemented; `snapshot()` is PGlite-only. */
39
+ export type DbDriver = "pglite" | "postgres";
40
+
41
+ /**
42
+ * Result kind shared by both drivers. drizzle's abstract `PgQueryResultHKT` only brands the
43
+ * kind (`type: unknown`), which would degrade every `execute()` result to `unknown`;
44
+ * PGlite and node-postgres both resolve to a `{ rows }` object, so this HKT restores the
45
+ * common surface for the driver-agnostic client type.
46
+ */
47
+ export interface FacilisQueryResultHKT extends PgQueryResultHKT {
48
+ readonly type: { readonly rows: Record<string, unknown>[] };
49
+ }
50
+
51
+ /**
52
+ * Root Drizzle client type. It is the only Drizzle client type allowed to leak past
53
+ * this package (AGENTS.md security rule 5 keeps raw Drizzle imports here). Both drivers
54
+ * cast their concrete client to this base class: the per-driver `$client` is never used
55
+ * here, so PGlite and node-postgres collapse to the shared Postgres query surface.
56
+ */
57
+ export type PgDatabaseLike = PgDatabase<FacilisQueryResultHKT, Record<string, unknown>>;
58
+
59
+ /** A Drizzle schema namespace, e.g. `import * as schema from "./db/schema.ts"`. */
60
+ export type FacilisSchema = Readonly<Record<string, unknown>>;
61
+
62
+ export interface CreateDbOptions {
63
+ readonly driver?: DbDriver;
64
+ readonly url?: string;
65
+ readonly dataDir?: string;
66
+ /**
67
+ * Restore a `snapshot()` archive instead of starting from an empty cluster.
68
+ * Dev persistence (D-052) keeps the cluster in memory and restores it from
69
+ * `.facilis/db/snapshot.tar.gz`; production uses a real Postgres instead.
70
+ */
71
+ readonly loadSnapshot?: Uint8Array;
72
+ /** Schema namespace used by migrations and by `schemaSnapshot`. */
73
+ readonly schema?: FacilisSchema;
74
+ /** Maximum pooled connections for driver "postgres" (`pg.Pool` `max`). Defaults to 10. */
75
+ readonly poolMax?: number;
76
+ }
77
+
78
+ export interface FacilisDb {
79
+ readonly driver: DbDriver;
80
+ readonly db: PgDatabaseLike;
81
+ readonly schema: FacilisSchema;
82
+ close(): Promise<void>;
83
+ /**
84
+ * Monotonic count of client operations (query/exec/transaction). The dev snapshotter
85
+ * polls this instead of every write: only a cluster that changed is dumped.
86
+ */
87
+ activity(): number;
88
+ /**
89
+ * Serialize the whole cluster as the gzip tar the `loadSnapshot` option restores.
90
+ * Only driver "pglite" can dump itself; driver "postgres" throws SnapshotUnavailableError.
91
+ */
92
+ snapshot(): Promise<Uint8Array>;
93
+ }
94
+
95
+ /** Column names treated as tenant scopes (SPEC §7.1 Appendix B defaults). */
96
+ export const DEFAULT_TENANT_COLUMNS: readonly string[] = ["tenantId", "tenant_id"];
97
+
98
+ /** Column names treated as per-row owner scopes (SPEC §7.1 Appendix B defaults). */
99
+ export const DEFAULT_OWNER_COLUMNS: readonly string[] = [
100
+ "ownerId",
101
+ "owner_id",
102
+ "userId",
103
+ "user_id",
104
+ ];
105
+
106
+ /** Thrown for a driver id that neither branch below can serve. */
107
+ export class DriverNotImplementedError extends Error {
108
+ readonly code = "driver_not_implemented";
109
+
110
+ constructor(driver: DbDriver) {
111
+ super(`The "${driver}" driver is not implemented; use driver "pglite" or "postgres".`);
112
+ this.name = "DriverNotImplementedError";
113
+ }
114
+ }
115
+
116
+ /** Thrown instead of resolving when the postgres pool cannot be opened or probed. */
117
+ export class DbConnectionError extends Error {
118
+ readonly code = "db_connection_failed";
119
+
120
+ constructor(cause: unknown) {
121
+ // The connection string may embed a password, so neither it nor the driver message
122
+ // (which repeats host/port) is forwarded into the public message.
123
+ super("The postgres driver could not connect; check the database settings.", { cause });
124
+ this.name = "DbConnectionError";
125
+ }
126
+ }
127
+
128
+ /** Thrown by snapshot() on a driver that has no hot cluster dump. */
129
+ export class SnapshotUnavailableError extends Error {
130
+ readonly code = "snapshot_unavailable";
131
+
132
+ constructor() {
133
+ super('The postgres driver has no cluster dump; snapshot() is available only for "pglite".');
134
+ this.name = "SnapshotUnavailableError";
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Opens the Facilis database. An explicit `url` without a driver means "postgres";
140
+ * `driver: "pglite"` is the embedded, in-memory or `dataDir` cluster.
141
+ */
142
+ export async function createDb(options: CreateDbOptions = {}): Promise<FacilisDb> {
143
+ const driver = options.driver ?? (options.url === undefined ? "pglite" : "postgres");
144
+ // facilis.config.ts is user code and may be plain JavaScript, so re-check the union.
145
+ if (driver !== "pglite" && driver !== "postgres") throw new DriverNotImplementedError(driver);
146
+ return driver === "postgres" ? openPostgresDb(options) : openPgliteDb(options);
147
+ }
148
+
149
+ async function openPgliteDb(options: CreateDbOptions): Promise<FacilisDb> {
150
+ const client = await openPglite(options);
151
+ const activity = { count: 0 };
152
+ // Drizzle talks to this facade; counting here is the only reliable "did the cluster
153
+ // change since the last dump" signal, because PGlite exposes no change hook. Methods
154
+ // are bound to the real client so `this` and the WASM instance are untouched.
155
+ const tracked = new Proxy(client, {
156
+ get(target, property, receiver) {
157
+ const value = Reflect.get(target, property, receiver);
158
+ if (typeof value !== "function") return value;
159
+ if (property === "query" || property === "exec" || property === "transaction") {
160
+ return (...args: unknown[]) => {
161
+ activity.count += 1;
162
+ return (value as (...inner: unknown[]) => unknown).apply(target, args);
163
+ };
164
+ }
165
+ return (value as (...inner: unknown[]) => unknown).bind(target);
166
+ },
167
+ }) as PgliteClient;
168
+
169
+ return {
170
+ driver: "pglite",
171
+ db: createPgliteDrizzleClient(tracked, options.schema),
172
+ schema: options.schema ?? {},
173
+ activity: () => activity.count,
174
+ async snapshot() {
175
+ const dump = await client.dumpDataDir("gzip");
176
+ return new Uint8Array(await dump.arrayBuffer());
177
+ },
178
+ async close() {
179
+ await client.close();
180
+ },
181
+ };
182
+ }
183
+
184
+ async function openPostgresDb(options: CreateDbOptions): Promise<FacilisDb> {
185
+ const { pg, drizzle: drizzlePg } = loadPostgres();
186
+ const url = options.url;
187
+ if (url === undefined) throw new DbConnectionError(new Error("no connection url was provided"));
188
+
189
+ const pool = new pg.Pool({
190
+ connectionString: url,
191
+ max: options.poolMax ?? 10,
192
+ application_name: "facilis",
193
+ // TLS comes from the connection string (e.g. ?sslmode=require) and pg parses it there;
194
+ // this layer must not re-implement it.
195
+ });
196
+
197
+ try {
198
+ // The pool is lazy: without the probe a bad URL would resolve createDb and only fail
199
+ // on the first request, which the framework treats as a runtime bug instead of config.
200
+ await pool.query("select 1");
201
+ } catch (cause) {
202
+ // The failed probe leaves the pool unusable; draining it is best effort.
203
+ await pool.end().catch(() => undefined);
204
+ throw new DbConnectionError(cause);
205
+ }
206
+
207
+ const schema = options.schema;
208
+ // Same generic collapse as the PGlite client: callers get builders from their tables.
209
+ const db = (schema === undefined
210
+ ? drizzlePg(pool)
211
+ : drizzlePg(pool, { schema: { ...schema } })) as unknown as PgDatabaseLike;
212
+
213
+ return {
214
+ driver: "postgres",
215
+ db,
216
+ schema: schema ?? {},
217
+ // The dev snapshotter (D-052) is installed only for driver "pglite" and polls that
218
+ // client's counter; a remote Postgres cluster has no equivalent hook.
219
+ activity: () => 0,
220
+ async snapshot(): Promise<Uint8Array> {
221
+ throw new SnapshotUnavailableError();
222
+ },
223
+ async close() {
224
+ await pool.end();
225
+ },
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Open the PGlite cluster: from a dev snapshot when one is supplied, else from
231
+ * `dataDir`, else empty.
232
+ *
233
+ * The restore goes through the documented static `create({ loadDataDir })`: the
234
+ * constructor overload accepts the option but does not wire it (measured), and a
235
+ * silently empty restore is the one failure a dev database must not have.
236
+ */
237
+ async function openPglite(options: CreateDbOptions): Promise<PgliteClient> {
238
+ const snapshot = options.loadSnapshot;
239
+ if (snapshot !== undefined && snapshot.length > 0) {
240
+ // Copy into a plain ArrayBuffer-backed view: a Uint8Array<ArrayBufferLike> is not a
241
+ // BlobPart (its buffer could be a SharedArrayBuffer), and the archive is small.
242
+ const bytes = new Uint8Array(snapshot.byteLength);
243
+ bytes.set(snapshot);
244
+ return (await PGlite.create({
245
+ loadDataDir: new Blob([bytes]),
246
+ })) as PgliteClient;
247
+ }
248
+ const client = options.dataDir === undefined ? new PGlite() : new PGlite(options.dataDir);
249
+ // PGlite boots lazily; awaiting the ready promise makes createDb's contract truthful.
250
+ await client.waitReady;
251
+ return client;
252
+ }
253
+
254
+ function createPgliteDrizzleClient(
255
+ client: PgliteClient,
256
+ schema: FacilisSchema | undefined,
257
+ ): PgDatabaseLike {
258
+ // The cast collapses Drizzle's schema-dependent generics: @facilis.dev/db does not consume
259
+ // the relational query API, and callers get typed query builders from their table objects.
260
+ if (schema === undefined) return drizzle(client) as unknown as PgDatabaseLike;
261
+ return drizzle(client, { schema: { ...schema } }) as unknown as PgDatabaseLike;
262
+ }
263
+
264
+ interface PostgresModules {
265
+ readonly pg: typeof import("pg");
266
+ readonly drizzle: typeof import("drizzle-orm/node-postgres").drizzle;
267
+ }
268
+
269
+ /**
270
+ * `pg` (node:net/node:tls) and drizzle's node-postgres driver are loaded lazily: the
271
+ * generated project runs server code inside a WebContainer, where the postgres branch is
272
+ * inert, and a static import would drag Node's socket stack into the in-VM bundle. Same
273
+ * two resolution bases as loadPglite: this module's URL, then the project root.
274
+ */
275
+ function loadPostgres(): PostgresModules {
276
+ let lastError: unknown;
277
+ for (const base of [import.meta.url, join(process.cwd(), "package.json")]) {
278
+ try {
279
+ const require = createRequire(base);
280
+ const pg = require("pg") as typeof import("pg");
281
+ const driver =
282
+ require("drizzle-orm/node-postgres") as typeof import("drizzle-orm/node-postgres");
283
+ return { pg, drizzle: driver.drizzle };
284
+ } catch (error) {
285
+ lastError = error;
286
+ }
287
+ }
288
+ throw new Error("facilis: cannot load pg / drizzle-orm/node-postgres — are they installed?", {
289
+ cause: lastError,
290
+ });
291
+ }
package/src/files.ts ADDED
@@ -0,0 +1,124 @@
1
+ // File metadata and per-entity attachment links (D-053).
2
+ //
3
+ // The bytes live in content-addressed storage (`@facilis.dev/storage`), never in Postgres:
4
+ // `facilis_files` is the metadata row (content hash, name, mime, size, owner), and a
5
+ // generated link table attaches a file to one row of an application table. Bytes are
6
+ // deduplicated by hash, so two uploads of the same content share one blob.
7
+ //
8
+ // The link table is generated rather than polymorphic on purpose: a real foreign key per
9
+ // entity keeps referential integrity (`ON DELETE CASCADE`), lets the manifest/OpenAPI name
10
+ // the relation, and inherits the parent's RLS through an EXISTS policy evaluated with the
11
+ // parent's own policies. A `(entity_table, entity_id)` pair would have none of that.
12
+ import { sql } from "drizzle-orm";
13
+ import {
14
+ bigint,
15
+ getTableConfig,
16
+ pgPolicy,
17
+ pgTable,
18
+ text,
19
+ timestamp,
20
+ uuid,
21
+ type PgColumn,
22
+ type PgTable,
23
+ } from "drizzle-orm/pg-core";
24
+
25
+ import { tenantIsolationPolicy } from "./rls.ts";
26
+ import { tenantColumn } from "./tenant-column.ts";
27
+
28
+ /**
29
+ * One uploaded file's metadata. The uniqueness of `hash` is a storage concern (the local
30
+ * driver and S3 both address by content), so the same bytes may have several rows with
31
+ * different names/mime types.
32
+ *
33
+ * Access to the bytes is decided by the routes that issue signed URLs, not by a per-row
34
+ * owner policy here: tenant isolation is the structural boundary, and a file attached to
35
+ * an entity is readable by whoever can read that entity. `owner_id` is the audit/issuer
36
+ * identity (and the owner check the upload route uses).
37
+ */
38
+ export const facilisFiles = pgTable(
39
+ "facilis_files",
40
+ {
41
+ id: uuid("id").primaryKey().defaultRandom(),
42
+ hash: text("hash").notNull(),
43
+ name: text("name").notNull(),
44
+ mime: text("mime").notNull(),
45
+ size: bigint("size", { mode: "number" }).notNull(),
46
+ tenantId: tenantColumn(),
47
+ ownerId: uuid("owner_id").notNull(),
48
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
49
+ },
50
+ () => [
51
+ tenantIsolationPolicy(),
52
+ // A RESTRICTIVE policy only narrows what a permissive policy already allows; with no
53
+ // permissive policy Postgres denies every row (D-008). Uploads insert a metadata row
54
+ // as facilis_app, so the scoped tenant needs this gate — like auth users and
55
+ // facilis_jobs. The RESTRICTIVE tenant policy still confines visible rows to the tenant.
56
+ pgPolicy("facilis_files_tenant_access", {
57
+ for: "all",
58
+ using: sql`nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
59
+ withCheck: sql`nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
60
+ }),
61
+ ],
62
+ ).enableRLS();
63
+
64
+ export type FacilisFile = typeof facilisFiles.$inferSelect;
65
+ export type NewFacilisFile = typeof facilisFiles.$inferInsert;
66
+
67
+ export interface FileLinksOptions {
68
+ /** Name of the generated link table, e.g. "todo_files". */
69
+ readonly table: string;
70
+ /** FK column name in the link table, e.g. "todo_id". Defaults to "parent_id". */
71
+ readonly column?: string;
72
+ }
73
+
74
+ /**
75
+ * Build the link table that lets one application table contain files.
76
+ *
77
+ * ```ts
78
+ * export const todoFiles = fileLinks(todos, { table: "todo_files", column: "todo_id" });
79
+ * ```
80
+ *
81
+ * The parent needs a single-column primary key (uuid recommended: the FK is a uuid). The
82
+ * generated table carries a restrictive tenant policy and a permissive `parent_access`
83
+ * policy: `EXISTS (SELECT 1 FROM parent WHERE parent.pk = link.parent_id)`. Postgres
84
+ * evaluates that subquery under the caller's role, so the parent table's policies decide
85
+ * visibility — the link inherits the parent's access rules instead of restating them.
86
+ */
87
+ export function fileLinks<TParent extends PgTable>(parent: TParent, options: FileLinksOptions) {
88
+ const config = getTableConfig(parent);
89
+ // A column-level `.primaryKey()` lives on the column, not in `primaryKeys` (which only
90
+ // records table-level composite keys), so collect both and require exactly one.
91
+ const parentKey = config.columns.find((column) => column.primary);
92
+ if (parentKey === undefined) {
93
+ throw new Error(
94
+ `fileLinks(${config.name}): the parent table needs exactly one primary-key column`,
95
+ );
96
+ }
97
+ const parentColumn: PgColumn = parentKey;
98
+ const parentTable: PgTable = parent;
99
+
100
+ return pgTable(
101
+ options.table,
102
+ {
103
+ id: uuid("id").primaryKey().defaultRandom(),
104
+ parentId: uuid(options.column ?? "parent_id")
105
+ .notNull()
106
+ .references(() => parentColumn, { onDelete: "cascade" }),
107
+ fileId: uuid("file_id")
108
+ .notNull()
109
+ .references(() => facilisFiles.id, { onDelete: "cascade" }),
110
+ tenantId: tenantColumn(),
111
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
112
+ },
113
+ (link) => [
114
+ tenantIsolationPolicy(),
115
+ pgPolicy("parent_access", {
116
+ for: "all",
117
+ using: sql`exists (select 1 from ${parentTable} where ${parentColumn} = ${link.parentId})`,
118
+ withCheck: sql`exists (select 1 from ${parentTable} where ${parentColumn} = ${link.parentId})`,
119
+ }),
120
+ ],
121
+ ).enableRLS();
122
+ }
123
+
124
+ export type FileLinksTable = ReturnType<typeof fileLinks>;
package/src/index.ts ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * @facilis.dev/db — Drizzle client for PGlite and Postgres, policy scope, RLS policy
3
+ * generators, schema snapshot and idempotent statement runner.
4
+ */
5
+
6
+ export {
7
+ createDb,
8
+ DbConnectionError,
9
+ DEFAULT_OWNER_COLUMNS,
10
+ DEFAULT_TENANT_COLUMNS,
11
+ DriverNotImplementedError,
12
+ SnapshotUnavailableError,
13
+ type CreateDbOptions,
14
+ type DbDriver,
15
+ type FacilisDb,
16
+ type FacilisSchema,
17
+ type PgDatabaseLike,
18
+ } from "./client.ts";
19
+ export {
20
+ APP_ROLE,
21
+ AppRoleUnavailableError,
22
+ ensureAppRole,
23
+ runWithPolicyScope,
24
+ type AppRoleProvisioning,
25
+ type PolicyScope,
26
+ type PolicyScopedDatabase,
27
+ type PolicyScopedTransaction,
28
+ } from "./policy.ts";
29
+ export {
30
+ ownerIsolationPolicy,
31
+ policyCreateSql,
32
+ rlsForTable,
33
+ rolePolicy,
34
+ tenantIsolationPolicy,
35
+ withRls,
36
+ type RlsExtras,
37
+ type RlsOptions,
38
+ } from "./rls.ts";
39
+ export { tenantColumn, tenantDefaultSql } from "./tenant-column.ts";
40
+ export {
41
+ facilisFiles,
42
+ fileLinks,
43
+ type FacilisFile,
44
+ type FileLinksOptions,
45
+ type FileLinksTable,
46
+ type NewFacilisFile,
47
+ } from "./files.ts";
48
+ export {
49
+ schemaSnapshot,
50
+ type CheckSnapshot,
51
+ type ColumnSnapshot,
52
+ type EnumSnapshot,
53
+ type ForeignKeySnapshot,
54
+ type IndexSnapshot,
55
+ type PolicySnapshot,
56
+ type SchemaSnapshot,
57
+ type TableSnapshot,
58
+ type UniqueConstraintSnapshot,
59
+ type UnsupportedDeclarationSnapshot,
60
+ } from "./snapshot.ts";
61
+ export {
62
+ liveSchemaSnapshot,
63
+ type LiveColumnSnapshot,
64
+ type LivePolicySnapshot,
65
+ type LiveSchemaSnapshot,
66
+ type LiveTableSnapshot,
67
+ } from "./live-schema.ts";
68
+ export {
69
+ canonicalColumnType,
70
+ diffSchemaSnapshots,
71
+ type SchemaDrift,
72
+ type SchemaDriftKind,
73
+ } from "./schema-drift.ts";
74
+ export {
75
+ canonicalDigestJson,
76
+ declaredDigestSchema,
77
+ digestSchema,
78
+ liveDigestSchema,
79
+ SCHEMA_DIGEST_VERSION,
80
+ type DigestColumn,
81
+ type DigestPolicy,
82
+ type DigestSchema,
83
+ type DigestTable,
84
+ } from "./schema-digest.ts";
85
+ export { applyMigrations, applyStatements, type MigrationSource } from "./migrator.ts";
86
+ export {
87
+ applyViews,
88
+ collectViewMetadata,
89
+ defineView,
90
+ grantViewSelect,
91
+ isFacilisView,
92
+ viewCreateStatement,
93
+ viewDefinitionStatement,
94
+ viewMetadata,
95
+ ViewDefinitionError,
96
+ VIEW_BRAND,
97
+ VIEW_NAME_PATTERN,
98
+ VIEW_PERMISSION_PATTERN,
99
+ type DefineViewOptions,
100
+ type FacilisView,
101
+ type ViewApplyFailure,
102
+ type ViewApplyOptions,
103
+ type ViewApplyResult,
104
+ type ViewErrorCode,
105
+ type ViewMetadata,
106
+ } from "./views.ts";
107
+ export {
108
+ ensureSystemRole,
109
+ runAsSystem,
110
+ runWithSystemRole,
111
+ SUDO_ID_GUC,
112
+ SYSTEM_ROLE,
113
+ type RunAsSystemOptions,
114
+ type RunWithSystemRoleOptions,
115
+ } from "./sudo.ts";
116
+ export {
117
+ FACILIS_JOB_STATUSES,
118
+ facilisJobs,
119
+ type FacilisJobRow,
120
+ type FacilisJobStatus,
121
+ type NewFacilisJobRow,
122
+ } from "./jobs.ts";
123
+ export {
124
+ FACILIS_TASK_DECISIONS,
125
+ FACILIS_TASK_KINDS,
126
+ FACILIS_TASK_STATUSES,
127
+ facilisTasks,
128
+ type FacilisTaskDecision,
129
+ type FacilisTaskKind,
130
+ type FacilisTaskRow,
131
+ type FacilisTaskStatus,
132
+ type NewFacilisTaskRow,
133
+ } from "./tasks.ts";
134
+ export {
135
+ FACILIS_WORKFLOW_STATUSES,
136
+ facilisWorkflows,
137
+ type FacilisWorkflowRow,
138
+ type FacilisWorkflowStatus,
139
+ type NewFacilisWorkflowRow,
140
+ } from "./workflows.ts";
141
+ export {
142
+ newSudoId,
143
+ resetSudoAuditSink,
144
+ setSudoAuditSink,
145
+ writeSudoAudit,
146
+ SudoAuditUnavailableError,
147
+ type SudoAuditEvent,
148
+ type SudoAuditEventName,
149
+ type SudoAuditSink,
150
+ } from "./sudo-audit.ts";
151
+ export {
152
+ firstKeyword,
153
+ hasMultipleStatements,
154
+ isReadOnlySql,
155
+ stripTrailingSemicolon,
156
+ } from "./read-only-sql.ts";
157
+ export {
158
+ guardDatabase,
159
+ TableAccessError,
160
+ type TableAccessErrorCode,
161
+ type TableGuardOptions,
162
+ } from "./table-guard.ts";
163
+ export {
164
+ createLiveView,
165
+ DEFAULT_QUERY_LIMIT,
166
+ MAX_QUERY_LIMIT,
167
+ QueryExecutionError,
168
+ QUERY_STATEMENT_TIMEOUT_MS,
169
+ runReadOnlyQuery,
170
+ toJsonValue,
171
+ type QueryColumn,
172
+ type QueryExecution,
173
+ type QueryExecutionErrorCode,
174
+ } from "./query-console.ts";
package/src/jobs.ts ADDED
@@ -0,0 +1,76 @@
1
+ // The persisted job queue (D-107, docs/JOBS.md §8).
2
+ //
3
+ // The queue is framework state, not application data: it is tenant-scoped so the structural
4
+ // invariant holds (`pgPolicy` + `ENABLE ROW LEVEL SECURITY`), the project re-exports the
5
+ // table from `db/schema.ts`, and the framework owns its DDL via a Drizzle migration. The
6
+ // engine's own writes run as the app role under the deployment tenant; a job *body* runs as
7
+ // the system role in its own audited transaction.
8
+ import { sql } from "drizzle-orm";
9
+ import { integer, jsonb, pgPolicy, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
10
+
11
+ import { tenantIsolationPolicy } from "./rls.ts";
12
+ import { tenantColumn } from "./tenant-column.ts";
13
+
14
+ /** Every `facilis_jobs.status` value, in lifecycle order. */
15
+ export const FACILIS_JOB_STATUSES = [
16
+ "queued",
17
+ "running",
18
+ "succeeded",
19
+ "failed",
20
+ "cancelled",
21
+ ] as const;
22
+
23
+ export type FacilisJobStatus = (typeof FACILIS_JOB_STATUSES)[number];
24
+
25
+ /**
26
+ * One job instance. `input`/`output` are JSONB because the job contract is serialized
27
+ * data (docs/JOBS.md §5); `status` is text rather than a Postgres enum so a new status is a
28
+ * code change, not a migration.
29
+ */
30
+ export const facilisJobs = pgTable(
31
+ "facilis_jobs",
32
+ {
33
+ id: uuid("id").primaryKey().defaultRandom(),
34
+ name: text("name").notNull(),
35
+ status: text("status").notNull(),
36
+ input: jsonb("input").notNull(),
37
+ output: jsonb("output"),
38
+ error: text("error"),
39
+ attempts: integer("attempts").notNull().default(0),
40
+ maxAttempts: integer("max_attempts").notNull(),
41
+ timeoutMs: integer("timeout_ms").notNull(),
42
+ /** When the job becomes eligible (a future instant means "scheduled"). */
43
+ runAt: timestamp("run_at", { withTimezone: true }).notNull().defaultNow(),
44
+ startedAt: timestamp("started_at", { withTimezone: true }),
45
+ finishedAt: timestamp("finished_at", { withTimezone: true }),
46
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
47
+ tenantId: tenantColumn(),
48
+ },
49
+ () => [
50
+ tenantIsolationPolicy(),
51
+ // RLS is tenant-only (D-008); the queue is additionally gated by app permissions, so a
52
+ // route that touches facilis_jobs without jobs:read/jobs:exec fails at the database, not
53
+ // only in its own wrapper check. The framework engine's scope passes both permissions
54
+ // explicitly (db-store); a request scope carries the session's expanded permissions.
55
+ pgPolicy("facilis_jobs_read", {
56
+ for: "select",
57
+ using: sql`nullif(current_setting('app.permissions', true), '')::jsonb ? 'jobs:read'`,
58
+ }),
59
+ pgPolicy("facilis_jobs_insert", {
60
+ for: "insert",
61
+ withCheck: sql`nullif(current_setting('app.permissions', true), '')::jsonb ? 'jobs:exec'`,
62
+ }),
63
+ pgPolicy("facilis_jobs_update", {
64
+ for: "update",
65
+ using: sql`nullif(current_setting('app.permissions', true), '')::jsonb ? 'jobs:exec'`,
66
+ withCheck: sql`nullif(current_setting('app.permissions', true), '')::jsonb ? 'jobs:exec'`,
67
+ }),
68
+ pgPolicy("facilis_jobs_delete", {
69
+ for: "delete",
70
+ using: sql`nullif(current_setting('app.permissions', true), '')::jsonb ? 'jobs:exec'`,
71
+ }),
72
+ ],
73
+ ).enableRLS();
74
+
75
+ export type FacilisJobRow = typeof facilisJobs.$inferSelect;
76
+ export type NewFacilisJobRow = typeof facilisJobs.$inferInsert;