@gencow/core 0.1.8 → 0.1.10

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/src/table.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * packages/core/src/table.ts
3
+ *
4
+ * gencowTable() — Drizzle pgTable wrapper with schema-level access control.
5
+ * Inspired by KeystoneJS's declarative filter pattern.
6
+ *
7
+ * Run tests: bun test packages/core/src/__tests__/table.test.ts
8
+ */
9
+
10
+ import { pgTable } from "drizzle-orm/pg-core";
11
+ import { eq } from "drizzle-orm";
12
+ import type { GencowCtx } from "./reactive";
13
+ import type { SQL } from "drizzle-orm";
14
+
15
+ // ─── Types ──────────────────────────────────────────────
16
+
17
+ /**
18
+ * Access control filter function.
19
+ * Returns a Drizzle SQL condition, `true` (allow all), or `false` (deny all).
20
+ * Can be async (e.g., for team membership lookups).
21
+ */
22
+ export type AccessFilter = (ctx: GencowCtx) => SQL | boolean | Promise<SQL | boolean>;
23
+
24
+ /** Field-level access control: per-field read permission. */
25
+ export interface FieldAccessRule {
26
+ read: (ctx: GencowCtx) => boolean;
27
+ }
28
+
29
+ /** Options for gencowTable — access control metadata. */
30
+ export interface GencowTableOptions {
31
+ /** Row-level filter — auto-injected into all queries on this table */
32
+ filter: AccessFilter;
33
+ /** Field-level access — unauthorized fields are nullified in results */
34
+ fieldAccess?: Record<string, FieldAccessRule>;
35
+ }
36
+
37
+ /** Stored metadata for a gencowTable. */
38
+ export interface TableAccessMeta {
39
+ filter: AccessFilter;
40
+ fieldAccess?: Record<string, FieldAccessRule>;
41
+ tableName: string;
42
+ }
43
+
44
+ // Internal marker for ownerFilter
45
+ interface OwnerFilterOptions extends GencowTableOptions {
46
+ _ownerColumn: string;
47
+ }
48
+
49
+ function isOwnerFilter(opts: GencowTableOptions): opts is OwnerFilterOptions {
50
+ return "_ownerColumn" in opts;
51
+ }
52
+
53
+ // ─── Global Registry ────────────────────────────────────
54
+
55
+ declare global {
56
+ // eslint-disable-next-line no-var
57
+ var __gencow_tableAccessRegistry: Map<any, TableAccessMeta>;
58
+ }
59
+
60
+ if (!globalThis.__gencow_tableAccessRegistry) {
61
+ globalThis.__gencow_tableAccessRegistry = new Map();
62
+ }
63
+
64
+ const tableAccessRegistry = globalThis.__gencow_tableAccessRegistry;
65
+
66
+ // ─── gencowTable() ──────────────────────────────────────
67
+
68
+ /**
69
+ * Drizzle pgTable wrapper with schema-level access control.
70
+ *
71
+ * Creates a standard Drizzle pgTable (100% compatible) and registers
72
+ * filter + fieldAccess metadata that ctx.db Proxy uses to auto-inject
73
+ * WHERE clauses.
74
+ *
75
+ * @param name - PostgreSQL table name
76
+ * @param columns - Drizzle column definitions (same as pgTable)
77
+ * @param options - Access control (filter required, fieldAccess optional)
78
+ */
79
+ export function gencowTable<T extends Record<string, any>>(
80
+ name: string,
81
+ columns: T,
82
+ options: GencowTableOptions,
83
+ ): ReturnType<typeof pgTable<string, T>> {
84
+ if (!options || (typeof options.filter !== "function" && !isOwnerFilter(options))) {
85
+ throw new Error(
86
+ `[gencow] gencowTable("${name}") requires a filter option. ` +
87
+ `Use ownerFilter("userId") for simple user isolation, ` +
88
+ `or { filter: () => true } for public tables.`
89
+ );
90
+ }
91
+
92
+ // Create standard Drizzle pgTable — fully compatible
93
+ const table = pgTable(name, columns);
94
+
95
+ // Resolve ownerFilter to a concrete filter bound to this table
96
+ let filter: AccessFilter;
97
+ if (isOwnerFilter(options)) {
98
+ const columnName = options._ownerColumn;
99
+ const col = (table as any)[columnName];
100
+ if (!col) {
101
+ throw new Error(
102
+ `[gencow] ownerFilter("${columnName}"): column "${columnName}" not found on table "${name}". ` +
103
+ `Available columns: ${Object.keys(table as any).filter(k => !k.startsWith("_") && !k.startsWith("$")).join(", ")}`
104
+ );
105
+ }
106
+ filter = (ctx: GencowCtx) => {
107
+ const user = ctx.auth.requireAuth();
108
+ return eq(col, user.id);
109
+ };
110
+ } else {
111
+ filter = options.filter;
112
+ }
113
+
114
+ // Store access control metadata keyed by the table object reference
115
+ tableAccessRegistry.set(table, {
116
+ filter,
117
+ fieldAccess: options.fieldAccess,
118
+ tableName: name,
119
+ });
120
+
121
+ return table;
122
+ }
123
+
124
+ // ─── ownerFilter() helper ───────────────────────────────
125
+
126
+ /**
127
+ * Convenience helper for userId-based isolation.
128
+ *
129
+ * Usage:
130
+ * export const tasks = gencowTable("tasks", { ... }, ownerFilter("userId"));
131
+ * export const files = gencowTable("files", { ... }, ownerFilter("ownerId"));
132
+ *
133
+ * @param columnName - Name of the user ID column (default: "userId")
134
+ */
135
+ export function ownerFilter(columnName: string = "userId"): GencowTableOptions {
136
+ // Return a marker object — gencowTable() resolves this to a concrete filter
137
+ // by binding to the actual table column at registration time.
138
+ return {
139
+ _ownerColumn: columnName,
140
+ // Placeholder filter — replaced by gencowTable()
141
+ filter: () => { throw new Error("[gencow] ownerFilter placeholder should not be called directly"); },
142
+ } as OwnerFilterOptions;
143
+ }
144
+
145
+ // ─── Lookup ─────────────────────────────────────────────
146
+
147
+ /** Get access control metadata for a table. Returns undefined for plain pgTable. */
148
+ export function getTableAccessMeta(table: any): TableAccessMeta | undefined {
149
+ return tableAccessRegistry.get(table);
150
+ }
151
+
152
+ /** Check if a table has gencowTable metadata registered. */
153
+ export function isGencowTable(table: any): boolean {
154
+ return tableAccessRegistry.has(table);
155
+ }
156
+
157
+ /** Get all registered gencowTables (for audit/boot-time checks). */
158
+ export function getAllGencowTables(): Map<any, TableAccessMeta> {
159
+ return new Map(tableAccessRegistry);
160
+ }
161
+
162
+ /** Reset registry (for testing only). */
163
+ export function _resetTableRegistry(): void {
164
+ tableAccessRegistry.clear();
165
+ }