@facilis.dev/db 0.0.0-stage → 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.
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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 facilis-dev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,19 @@
1
- # Temporary Holding Version
1
+ # @facilis.dev/db
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Facilis database layer: Drizzle + PGlite client, RLS policy helpers and release diffing.
4
+
5
+ Part of [Facilis](https://github.com/facilis-dev/facilis), a browser-first low-code framework: the Studio
6
+ runs a generated project in a WebContainer where vite dev is the only dev server
7
+ and Nitro v3 runs as a Vite plugin. Framework contracts live in
8
+ [docs/SPEC.md](https://github.com/facilis-dev/facilis/blob/main/docs/SPEC.md) and architectural decisions in
9
+ [docs/DECISIONS.md](https://github.com/facilis-dev/facilis/blob/main/docs/DECISIONS.md).
10
+
11
+ ```sh
12
+ npm install @facilis.dev/db
13
+ ```
14
+
15
+ The package publishes its TypeScript sources and its compiled dist/; the exports map
16
+ of the published manifest points at dist/, so Node and bundlers load JavaScript and
17
+ the bundled .d.ts files.
18
+
19
+ Licensed under the MIT License.
@@ -0,0 +1,78 @@
1
+ import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
2
+ /** Drivers the public API promises. Both are implemented; `snapshot()` is PGlite-only. */
3
+ export type DbDriver = "pglite" | "postgres";
4
+ /**
5
+ * Result kind shared by both drivers. drizzle's abstract `PgQueryResultHKT` only brands the
6
+ * kind (`type: unknown`), which would degrade every `execute()` result to `unknown`;
7
+ * PGlite and node-postgres both resolve to a `{ rows }` object, so this HKT restores the
8
+ * common surface for the driver-agnostic client type.
9
+ */
10
+ export interface FacilisQueryResultHKT extends PgQueryResultHKT {
11
+ readonly type: {
12
+ readonly rows: Record<string, unknown>[];
13
+ };
14
+ }
15
+ /**
16
+ * Root Drizzle client type. It is the only Drizzle client type allowed to leak past
17
+ * this package (AGENTS.md security rule 5 keeps raw Drizzle imports here). Both drivers
18
+ * cast their concrete client to this base class: the per-driver `$client` is never used
19
+ * here, so PGlite and node-postgres collapse to the shared Postgres query surface.
20
+ */
21
+ export type PgDatabaseLike = PgDatabase<FacilisQueryResultHKT, Record<string, unknown>>;
22
+ /** A Drizzle schema namespace, e.g. `import * as schema from "./db/schema.ts"`. */
23
+ export type FacilisSchema = Readonly<Record<string, unknown>>;
24
+ export interface CreateDbOptions {
25
+ readonly driver?: DbDriver;
26
+ readonly url?: string;
27
+ readonly dataDir?: string;
28
+ /**
29
+ * Restore a `snapshot()` archive instead of starting from an empty cluster.
30
+ * Dev persistence (D-052) keeps the cluster in memory and restores it from
31
+ * `.facilis/db/snapshot.tar.gz`; production uses a real Postgres instead.
32
+ */
33
+ readonly loadSnapshot?: Uint8Array;
34
+ /** Schema namespace used by migrations and by `schemaSnapshot`. */
35
+ readonly schema?: FacilisSchema;
36
+ /** Maximum pooled connections for driver "postgres" (`pg.Pool` `max`). Defaults to 10. */
37
+ readonly poolMax?: number;
38
+ }
39
+ export interface FacilisDb {
40
+ readonly driver: DbDriver;
41
+ readonly db: PgDatabaseLike;
42
+ readonly schema: FacilisSchema;
43
+ close(): Promise<void>;
44
+ /**
45
+ * Monotonic count of client operations (query/exec/transaction). The dev snapshotter
46
+ * polls this instead of every write: only a cluster that changed is dumped.
47
+ */
48
+ activity(): number;
49
+ /**
50
+ * Serialize the whole cluster as the gzip tar the `loadSnapshot` option restores.
51
+ * Only driver "pglite" can dump itself; driver "postgres" throws SnapshotUnavailableError.
52
+ */
53
+ snapshot(): Promise<Uint8Array>;
54
+ }
55
+ /** Column names treated as tenant scopes (SPEC §7.1 Appendix B defaults). */
56
+ export declare const DEFAULT_TENANT_COLUMNS: readonly string[];
57
+ /** Column names treated as per-row owner scopes (SPEC §7.1 Appendix B defaults). */
58
+ export declare const DEFAULT_OWNER_COLUMNS: readonly string[];
59
+ /** Thrown for a driver id that neither branch below can serve. */
60
+ export declare class DriverNotImplementedError extends Error {
61
+ readonly code = "driver_not_implemented";
62
+ constructor(driver: DbDriver);
63
+ }
64
+ /** Thrown instead of resolving when the postgres pool cannot be opened or probed. */
65
+ export declare class DbConnectionError extends Error {
66
+ readonly code = "db_connection_failed";
67
+ constructor(cause: unknown);
68
+ }
69
+ /** Thrown by snapshot() on a driver that has no hot cluster dump. */
70
+ export declare class SnapshotUnavailableError extends Error {
71
+ readonly code = "snapshot_unavailable";
72
+ constructor();
73
+ }
74
+ /**
75
+ * Opens the Facilis database. An explicit `url` without a driver means "postgres";
76
+ * `driver: "pglite"` is the embedded, in-memory or `dataDir` cluster.
77
+ */
78
+ export declare function createDb(options?: CreateDbOptions): Promise<FacilisDb>;
package/dist/client.js ADDED
@@ -0,0 +1,208 @@
1
+ import { createRequire } from "node:module";
2
+ import { join } from "node:path";
3
+ import { drizzle } from "drizzle-orm/pglite";
4
+ /**
5
+ * PGlite must come from its CJS build (vendor/vivari/AGENTS.md, "In-VM databases"):
6
+ * the ESM build locates `pglite.wasm` and `pglite.data` through `import.meta.url`, and
7
+ * that URL is only as good as the module id the bundler handed the module. A relative id
8
+ * becomes `file://home/…` — the first segment parses as a URL *host* — so the resolved
9
+ * path loses `/home` (`/user/projects/…`) and the DB cannot boot with ENOENT. The CJS
10
+ * build resolves the same artifacts from `__filename`, a real VFS path, through
11
+ * `new URL("file:" + __filename)` = `file:/home/…`; the pinned runtime converts that back
12
+ * to the intended path even where the browser parses it as hostful, i.e. on Chrome/Windows
13
+ * (fork `e654919`, D-039 and D-041).
14
+ *
15
+ * Two resolution bases: this module's own URL (works in Node and under Vitest), then the
16
+ * project root, which is the cwd the in-VM Nitro dev server runs in.
17
+ */
18
+ function loadPglite() {
19
+ let lastError;
20
+ for (const base of [import.meta.url, join(process.cwd(), "package.json")]) {
21
+ try {
22
+ return createRequire(base)("@electric-sql/pglite");
23
+ }
24
+ catch (error) {
25
+ lastError = error;
26
+ }
27
+ }
28
+ throw new Error("facilis: cannot load @electric-sql/pglite — is it installed?", {
29
+ cause: lastError,
30
+ });
31
+ }
32
+ const { PGlite } = loadPglite();
33
+ /** Column names treated as tenant scopes (SPEC §7.1 Appendix B defaults). */
34
+ export const DEFAULT_TENANT_COLUMNS = ["tenantId", "tenant_id"];
35
+ /** Column names treated as per-row owner scopes (SPEC §7.1 Appendix B defaults). */
36
+ export const DEFAULT_OWNER_COLUMNS = [
37
+ "ownerId",
38
+ "owner_id",
39
+ "userId",
40
+ "user_id",
41
+ ];
42
+ /** Thrown for a driver id that neither branch below can serve. */
43
+ export class DriverNotImplementedError extends Error {
44
+ code = "driver_not_implemented";
45
+ constructor(driver) {
46
+ super(`The "${driver}" driver is not implemented; use driver "pglite" or "postgres".`);
47
+ this.name = "DriverNotImplementedError";
48
+ }
49
+ }
50
+ /** Thrown instead of resolving when the postgres pool cannot be opened or probed. */
51
+ export class DbConnectionError extends Error {
52
+ code = "db_connection_failed";
53
+ constructor(cause) {
54
+ // The connection string may embed a password, so neither it nor the driver message
55
+ // (which repeats host/port) is forwarded into the public message.
56
+ super("The postgres driver could not connect; check the database settings.", { cause });
57
+ this.name = "DbConnectionError";
58
+ }
59
+ }
60
+ /** Thrown by snapshot() on a driver that has no hot cluster dump. */
61
+ export class SnapshotUnavailableError extends Error {
62
+ code = "snapshot_unavailable";
63
+ constructor() {
64
+ super('The postgres driver has no cluster dump; snapshot() is available only for "pglite".');
65
+ this.name = "SnapshotUnavailableError";
66
+ }
67
+ }
68
+ /**
69
+ * Opens the Facilis database. An explicit `url` without a driver means "postgres";
70
+ * `driver: "pglite"` is the embedded, in-memory or `dataDir` cluster.
71
+ */
72
+ export async function createDb(options = {}) {
73
+ const driver = options.driver ?? (options.url === undefined ? "pglite" : "postgres");
74
+ // facilis.config.ts is user code and may be plain JavaScript, so re-check the union.
75
+ if (driver !== "pglite" && driver !== "postgres")
76
+ throw new DriverNotImplementedError(driver);
77
+ return driver === "postgres" ? openPostgresDb(options) : openPgliteDb(options);
78
+ }
79
+ async function openPgliteDb(options) {
80
+ const client = await openPglite(options);
81
+ const activity = { count: 0 };
82
+ // Drizzle talks to this facade; counting here is the only reliable "did the cluster
83
+ // change since the last dump" signal, because PGlite exposes no change hook. Methods
84
+ // are bound to the real client so `this` and the WASM instance are untouched.
85
+ const tracked = new Proxy(client, {
86
+ get(target, property, receiver) {
87
+ const value = Reflect.get(target, property, receiver);
88
+ if (typeof value !== "function")
89
+ return value;
90
+ if (property === "query" || property === "exec" || property === "transaction") {
91
+ return (...args) => {
92
+ activity.count += 1;
93
+ return value.apply(target, args);
94
+ };
95
+ }
96
+ return value.bind(target);
97
+ },
98
+ });
99
+ return {
100
+ driver: "pglite",
101
+ db: createPgliteDrizzleClient(tracked, options.schema),
102
+ schema: options.schema ?? {},
103
+ activity: () => activity.count,
104
+ async snapshot() {
105
+ const dump = await client.dumpDataDir("gzip");
106
+ return new Uint8Array(await dump.arrayBuffer());
107
+ },
108
+ async close() {
109
+ await client.close();
110
+ },
111
+ };
112
+ }
113
+ async function openPostgresDb(options) {
114
+ const { pg, drizzle: drizzlePg } = loadPostgres();
115
+ const url = options.url;
116
+ if (url === undefined)
117
+ throw new DbConnectionError(new Error("no connection url was provided"));
118
+ const pool = new pg.Pool({
119
+ connectionString: url,
120
+ max: options.poolMax ?? 10,
121
+ application_name: "facilis",
122
+ // TLS comes from the connection string (e.g. ?sslmode=require) and pg parses it there;
123
+ // this layer must not re-implement it.
124
+ });
125
+ try {
126
+ // The pool is lazy: without the probe a bad URL would resolve createDb and only fail
127
+ // on the first request, which the framework treats as a runtime bug instead of config.
128
+ await pool.query("select 1");
129
+ }
130
+ catch (cause) {
131
+ // The failed probe leaves the pool unusable; draining it is best effort.
132
+ await pool.end().catch(() => undefined);
133
+ throw new DbConnectionError(cause);
134
+ }
135
+ const schema = options.schema;
136
+ // Same generic collapse as the PGlite client: callers get builders from their tables.
137
+ const db = (schema === undefined
138
+ ? drizzlePg(pool)
139
+ : drizzlePg(pool, { schema: { ...schema } }));
140
+ return {
141
+ driver: "postgres",
142
+ db,
143
+ schema: schema ?? {},
144
+ // The dev snapshotter (D-052) is installed only for driver "pglite" and polls that
145
+ // client's counter; a remote Postgres cluster has no equivalent hook.
146
+ activity: () => 0,
147
+ async snapshot() {
148
+ throw new SnapshotUnavailableError();
149
+ },
150
+ async close() {
151
+ await pool.end();
152
+ },
153
+ };
154
+ }
155
+ /**
156
+ * Open the PGlite cluster: from a dev snapshot when one is supplied, else from
157
+ * `dataDir`, else empty.
158
+ *
159
+ * The restore goes through the documented static `create({ loadDataDir })`: the
160
+ * constructor overload accepts the option but does not wire it (measured), and a
161
+ * silently empty restore is the one failure a dev database must not have.
162
+ */
163
+ async function openPglite(options) {
164
+ const snapshot = options.loadSnapshot;
165
+ if (snapshot !== undefined && snapshot.length > 0) {
166
+ // Copy into a plain ArrayBuffer-backed view: a Uint8Array<ArrayBufferLike> is not a
167
+ // BlobPart (its buffer could be a SharedArrayBuffer), and the archive is small.
168
+ const bytes = new Uint8Array(snapshot.byteLength);
169
+ bytes.set(snapshot);
170
+ return (await PGlite.create({
171
+ loadDataDir: new Blob([bytes]),
172
+ }));
173
+ }
174
+ const client = options.dataDir === undefined ? new PGlite() : new PGlite(options.dataDir);
175
+ // PGlite boots lazily; awaiting the ready promise makes createDb's contract truthful.
176
+ await client.waitReady;
177
+ return client;
178
+ }
179
+ function createPgliteDrizzleClient(client, schema) {
180
+ // The cast collapses Drizzle's schema-dependent generics: @facilis.dev/db does not consume
181
+ // the relational query API, and callers get typed query builders from their table objects.
182
+ if (schema === undefined)
183
+ return drizzle(client);
184
+ return drizzle(client, { schema: { ...schema } });
185
+ }
186
+ /**
187
+ * `pg` (node:net/node:tls) and drizzle's node-postgres driver are loaded lazily: the
188
+ * generated project runs server code inside a WebContainer, where the postgres branch is
189
+ * inert, and a static import would drag Node's socket stack into the in-VM bundle. Same
190
+ * two resolution bases as loadPglite: this module's URL, then the project root.
191
+ */
192
+ function loadPostgres() {
193
+ let lastError;
194
+ for (const base of [import.meta.url, join(process.cwd(), "package.json")]) {
195
+ try {
196
+ const require = createRequire(base);
197
+ const pg = require("pg");
198
+ const driver = require("drizzle-orm/node-postgres");
199
+ return { pg, drizzle: driver.drizzle };
200
+ }
201
+ catch (error) {
202
+ lastError = error;
203
+ }
204
+ }
205
+ throw new Error("facilis: cannot load pg / drizzle-orm/node-postgres — are they installed?", {
206
+ cause: lastError,
207
+ });
208
+ }
@@ -0,0 +1,268 @@
1
+ import { type PgColumn, type PgTable } from "drizzle-orm/pg-core";
2
+ /**
3
+ * One uploaded file's metadata. The uniqueness of `hash` is a storage concern (the local
4
+ * driver and S3 both address by content), so the same bytes may have several rows with
5
+ * different names/mime types.
6
+ *
7
+ * Access to the bytes is decided by the routes that issue signed URLs, not by a per-row
8
+ * owner policy here: tenant isolation is the structural boundary, and a file attached to
9
+ * an entity is readable by whoever can read that entity. `owner_id` is the audit/issuer
10
+ * identity (and the owner check the upload route uses).
11
+ */
12
+ export declare const facilisFiles: Omit<import("drizzle-orm/pg-core").PgTableWithColumns<{
13
+ name: "facilis_files";
14
+ schema: undefined;
15
+ columns: {
16
+ id: PgColumn<{
17
+ name: "id";
18
+ tableName: "facilis_files";
19
+ dataType: "string";
20
+ columnType: "PgUUID";
21
+ data: string;
22
+ driverParam: string;
23
+ notNull: true;
24
+ hasDefault: true;
25
+ isPrimaryKey: true;
26
+ isAutoincrement: false;
27
+ hasRuntimeDefault: false;
28
+ enumValues: undefined;
29
+ baseColumn: never;
30
+ identity: undefined;
31
+ generated: undefined;
32
+ }, {}, {}>;
33
+ hash: PgColumn<{
34
+ name: "hash";
35
+ tableName: "facilis_files";
36
+ dataType: "string";
37
+ columnType: "PgText";
38
+ data: string;
39
+ driverParam: string;
40
+ notNull: true;
41
+ hasDefault: false;
42
+ isPrimaryKey: false;
43
+ isAutoincrement: false;
44
+ hasRuntimeDefault: false;
45
+ enumValues: [string, ...string[]];
46
+ baseColumn: never;
47
+ identity: undefined;
48
+ generated: undefined;
49
+ }, {}, {}>;
50
+ name: PgColumn<{
51
+ name: "name";
52
+ tableName: "facilis_files";
53
+ dataType: "string";
54
+ columnType: "PgText";
55
+ data: string;
56
+ driverParam: string;
57
+ notNull: true;
58
+ hasDefault: false;
59
+ isPrimaryKey: false;
60
+ isAutoincrement: false;
61
+ hasRuntimeDefault: false;
62
+ enumValues: [string, ...string[]];
63
+ baseColumn: never;
64
+ identity: undefined;
65
+ generated: undefined;
66
+ }, {}, {}>;
67
+ mime: PgColumn<{
68
+ name: "mime";
69
+ tableName: "facilis_files";
70
+ dataType: "string";
71
+ columnType: "PgText";
72
+ data: string;
73
+ driverParam: string;
74
+ notNull: true;
75
+ hasDefault: false;
76
+ isPrimaryKey: false;
77
+ isAutoincrement: false;
78
+ hasRuntimeDefault: false;
79
+ enumValues: [string, ...string[]];
80
+ baseColumn: never;
81
+ identity: undefined;
82
+ generated: undefined;
83
+ }, {}, {}>;
84
+ size: PgColumn<{
85
+ name: "size";
86
+ tableName: "facilis_files";
87
+ dataType: "number";
88
+ columnType: "PgBigInt53";
89
+ data: number;
90
+ driverParam: string | number;
91
+ notNull: true;
92
+ hasDefault: false;
93
+ isPrimaryKey: false;
94
+ isAutoincrement: false;
95
+ hasRuntimeDefault: false;
96
+ enumValues: undefined;
97
+ baseColumn: never;
98
+ identity: undefined;
99
+ generated: undefined;
100
+ }, {}, {}>;
101
+ tenantId: PgColumn<{
102
+ name: string;
103
+ tableName: "facilis_files";
104
+ dataType: "string";
105
+ columnType: "PgUUID";
106
+ data: string;
107
+ driverParam: string;
108
+ notNull: true;
109
+ hasDefault: true;
110
+ isPrimaryKey: false;
111
+ isAutoincrement: false;
112
+ hasRuntimeDefault: false;
113
+ enumValues: undefined;
114
+ baseColumn: never;
115
+ identity: undefined;
116
+ generated: undefined;
117
+ }, {}, {}>;
118
+ ownerId: PgColumn<{
119
+ name: "owner_id";
120
+ tableName: "facilis_files";
121
+ dataType: "string";
122
+ columnType: "PgUUID";
123
+ data: string;
124
+ driverParam: string;
125
+ notNull: true;
126
+ hasDefault: false;
127
+ isPrimaryKey: false;
128
+ isAutoincrement: false;
129
+ hasRuntimeDefault: false;
130
+ enumValues: undefined;
131
+ baseColumn: never;
132
+ identity: undefined;
133
+ generated: undefined;
134
+ }, {}, {}>;
135
+ createdAt: PgColumn<{
136
+ name: "created_at";
137
+ tableName: "facilis_files";
138
+ dataType: "date";
139
+ columnType: "PgTimestamp";
140
+ data: Date;
141
+ driverParam: string;
142
+ notNull: true;
143
+ hasDefault: true;
144
+ isPrimaryKey: false;
145
+ isAutoincrement: false;
146
+ hasRuntimeDefault: false;
147
+ enumValues: undefined;
148
+ baseColumn: never;
149
+ identity: undefined;
150
+ generated: undefined;
151
+ }, {}, {}>;
152
+ };
153
+ dialect: 'pg';
154
+ }>, "enableRLS">;
155
+ export type FacilisFile = typeof facilisFiles.$inferSelect;
156
+ export type NewFacilisFile = typeof facilisFiles.$inferInsert;
157
+ export interface FileLinksOptions {
158
+ /** Name of the generated link table, e.g. "todo_files". */
159
+ readonly table: string;
160
+ /** FK column name in the link table, e.g. "todo_id". Defaults to "parent_id". */
161
+ readonly column?: string;
162
+ }
163
+ /**
164
+ * Build the link table that lets one application table contain files.
165
+ *
166
+ * ```ts
167
+ * export const todoFiles = fileLinks(todos, { table: "todo_files", column: "todo_id" });
168
+ * ```
169
+ *
170
+ * The parent needs a single-column primary key (uuid recommended: the FK is a uuid). The
171
+ * generated table carries a restrictive tenant policy and a permissive `parent_access`
172
+ * policy: `EXISTS (SELECT 1 FROM parent WHERE parent.pk = link.parent_id)`. Postgres
173
+ * evaluates that subquery under the caller's role, so the parent table's policies decide
174
+ * visibility — the link inherits the parent's access rules instead of restating them.
175
+ */
176
+ export declare function fileLinks<TParent extends PgTable>(parent: TParent, options: FileLinksOptions): Omit<import("drizzle-orm/pg-core").PgTableWithColumns<{
177
+ name: string;
178
+ schema: undefined;
179
+ columns: {
180
+ id: PgColumn<{
181
+ name: "id";
182
+ tableName: string;
183
+ dataType: "string";
184
+ columnType: "PgUUID";
185
+ data: string;
186
+ driverParam: string;
187
+ notNull: true;
188
+ hasDefault: true;
189
+ isPrimaryKey: true;
190
+ isAutoincrement: false;
191
+ hasRuntimeDefault: false;
192
+ enumValues: undefined;
193
+ baseColumn: never;
194
+ identity: undefined;
195
+ generated: undefined;
196
+ }, {}, {}>;
197
+ parentId: PgColumn<{
198
+ name: string;
199
+ tableName: string;
200
+ dataType: "string";
201
+ columnType: "PgUUID";
202
+ data: string;
203
+ driverParam: string;
204
+ notNull: true;
205
+ hasDefault: false;
206
+ isPrimaryKey: false;
207
+ isAutoincrement: false;
208
+ hasRuntimeDefault: false;
209
+ enumValues: undefined;
210
+ baseColumn: never;
211
+ identity: undefined;
212
+ generated: undefined;
213
+ }, {}, {}>;
214
+ fileId: PgColumn<{
215
+ name: "file_id";
216
+ tableName: string;
217
+ dataType: "string";
218
+ columnType: "PgUUID";
219
+ data: string;
220
+ driverParam: string;
221
+ notNull: true;
222
+ hasDefault: false;
223
+ isPrimaryKey: false;
224
+ isAutoincrement: false;
225
+ hasRuntimeDefault: false;
226
+ enumValues: undefined;
227
+ baseColumn: never;
228
+ identity: undefined;
229
+ generated: undefined;
230
+ }, {}, {}>;
231
+ tenantId: PgColumn<{
232
+ name: string;
233
+ tableName: string;
234
+ dataType: "string";
235
+ columnType: "PgUUID";
236
+ data: string;
237
+ driverParam: string;
238
+ notNull: true;
239
+ hasDefault: true;
240
+ isPrimaryKey: false;
241
+ isAutoincrement: false;
242
+ hasRuntimeDefault: false;
243
+ enumValues: undefined;
244
+ baseColumn: never;
245
+ identity: undefined;
246
+ generated: undefined;
247
+ }, {}, {}>;
248
+ createdAt: PgColumn<{
249
+ name: "created_at";
250
+ tableName: string;
251
+ dataType: "date";
252
+ columnType: "PgTimestamp";
253
+ data: Date;
254
+ driverParam: string;
255
+ notNull: true;
256
+ hasDefault: true;
257
+ isPrimaryKey: false;
258
+ isAutoincrement: false;
259
+ hasRuntimeDefault: false;
260
+ enumValues: undefined;
261
+ baseColumn: never;
262
+ identity: undefined;
263
+ generated: undefined;
264
+ }, {}, {}>;
265
+ };
266
+ dialect: 'pg';
267
+ }>, "enableRLS">;
268
+ export type FileLinksTable = ReturnType<typeof fileLinks>;
package/dist/files.js ADDED
@@ -0,0 +1,88 @@
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 { bigint, getTableConfig, pgPolicy, pgTable, text, timestamp, uuid, } from "drizzle-orm/pg-core";
14
+ import { tenantIsolationPolicy } from "./rls.js";
15
+ import { tenantColumn } from "./tenant-column.js";
16
+ /**
17
+ * One uploaded file's metadata. The uniqueness of `hash` is a storage concern (the local
18
+ * driver and S3 both address by content), so the same bytes may have several rows with
19
+ * different names/mime types.
20
+ *
21
+ * Access to the bytes is decided by the routes that issue signed URLs, not by a per-row
22
+ * owner policy here: tenant isolation is the structural boundary, and a file attached to
23
+ * an entity is readable by whoever can read that entity. `owner_id` is the audit/issuer
24
+ * identity (and the owner check the upload route uses).
25
+ */
26
+ export const facilisFiles = pgTable("facilis_files", {
27
+ id: uuid("id").primaryKey().defaultRandom(),
28
+ hash: text("hash").notNull(),
29
+ name: text("name").notNull(),
30
+ mime: text("mime").notNull(),
31
+ size: bigint("size", { mode: "number" }).notNull(),
32
+ tenantId: tenantColumn(),
33
+ ownerId: uuid("owner_id").notNull(),
34
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
35
+ }, () => [
36
+ tenantIsolationPolicy(),
37
+ // A RESTRICTIVE policy only narrows what a permissive policy already allows; with no
38
+ // permissive policy Postgres denies every row (D-008). Uploads insert a metadata row
39
+ // as facilis_app, so the scoped tenant needs this gate — like auth users and
40
+ // facilis_jobs. The RESTRICTIVE tenant policy still confines visible rows to the tenant.
41
+ pgPolicy("facilis_files_tenant_access", {
42
+ for: "all",
43
+ using: sql `nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
44
+ withCheck: sql `nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
45
+ }),
46
+ ]).enableRLS();
47
+ /**
48
+ * Build the link table that lets one application table contain files.
49
+ *
50
+ * ```ts
51
+ * export const todoFiles = fileLinks(todos, { table: "todo_files", column: "todo_id" });
52
+ * ```
53
+ *
54
+ * The parent needs a single-column primary key (uuid recommended: the FK is a uuid). The
55
+ * generated table carries a restrictive tenant policy and a permissive `parent_access`
56
+ * policy: `EXISTS (SELECT 1 FROM parent WHERE parent.pk = link.parent_id)`. Postgres
57
+ * evaluates that subquery under the caller's role, so the parent table's policies decide
58
+ * visibility — the link inherits the parent's access rules instead of restating them.
59
+ */
60
+ export function fileLinks(parent, options) {
61
+ const config = getTableConfig(parent);
62
+ // A column-level `.primaryKey()` lives on the column, not in `primaryKeys` (which only
63
+ // records table-level composite keys), so collect both and require exactly one.
64
+ const parentKey = config.columns.find((column) => column.primary);
65
+ if (parentKey === undefined) {
66
+ throw new Error(`fileLinks(${config.name}): the parent table needs exactly one primary-key column`);
67
+ }
68
+ const parentColumn = parentKey;
69
+ const parentTable = parent;
70
+ return pgTable(options.table, {
71
+ id: uuid("id").primaryKey().defaultRandom(),
72
+ parentId: uuid(options.column ?? "parent_id")
73
+ .notNull()
74
+ .references(() => parentColumn, { onDelete: "cascade" }),
75
+ fileId: uuid("file_id")
76
+ .notNull()
77
+ .references(() => facilisFiles.id, { onDelete: "cascade" }),
78
+ tenantId: tenantColumn(),
79
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
80
+ }, (link) => [
81
+ tenantIsolationPolicy(),
82
+ pgPolicy("parent_access", {
83
+ for: "all",
84
+ using: sql `exists (select 1 from ${parentTable} where ${parentColumn} = ${link.parentId})`,
85
+ withCheck: sql `exists (select 1 from ${parentTable} where ${parentColumn} = ${link.parentId})`,
86
+ }),
87
+ ]).enableRLS();
88
+ }