@thomasfosterau/effect-drizzle-ext-pg 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.
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @copyright Thomas Foster
3
+ *
4
+ * **The view-on-view gate** (`DB-VIEW-001`, website #1228): fails when any
5
+ * database view in a Drizzle schema selects from another database view.
6
+ * Ported from the website and gardeneria applications'
7
+ * `packages/database/scripts/check-view-dependencies.ts`.
8
+ *
9
+ * drizzle-kit derives migrations from a schema diff, and for views that diff
10
+ * is only ever `DROP VIEW` + `CREATE VIEW`, emitted as two flat blocks in one
11
+ * shared order with **no topological sort** and **no cascade**. A
12
+ * view-on-view edge is then unmigratable two ways:
13
+ *
14
+ * 1. **Both ends change.** The drops come out in the create order, so a drop
15
+ * runs dependency-first and PostgreSQL rejects it.
16
+ * 2. **Only the dependency changes.** The dependent view is not in the diff
17
+ * at all, the dependency's `DROP VIEW` still fails, and no reordering of
18
+ * the emitted statements can help: the one that would have to run first
19
+ * was never emitted.
20
+ *
21
+ * The only remedy once such a migration exists is hand-editing it, so the
22
+ * constraint is pushed up into the schema: **no view may reference another
23
+ * view**. The graph is read from drizzle-kit's own DDL — the whole schema
24
+ * generated from an empty snapshot, one `CREATE VIEW` per view carrying the
25
+ * exact SQL a migration would — so the gate needs no database.
26
+ *
27
+ * @module tooling/view-dependencies
28
+ */
29
+
30
+ import { generateDrizzleJson, generateMigration } from "drizzle-kit/api-postgres";
31
+ import * as Arr from "effect/Array";
32
+ import * as Effect from "effect/Effect";
33
+ import * as Order from "effect/Order";
34
+ import * as Record from "effect/Record";
35
+ import * as Schema from "effect/Schema";
36
+ import { drizzleKit, type SchemaDiffError } from "./migration-drift.ts";
37
+
38
+ /** One view as drizzle-kit would create it. */
39
+ export interface ViewDefinition {
40
+ readonly schema: string;
41
+ readonly name: string;
42
+ readonly sql: string;
43
+ }
44
+
45
+ /** A view that selects from another view. */
46
+ export interface ViewEdge {
47
+ readonly from: ViewDefinition;
48
+ readonly to: ViewDefinition;
49
+ }
50
+
51
+ /**
52
+ * `CREATE [MATERIALIZED] VIEW "<schema>"."<name>"` — drizzle-kit omits the
53
+ * schema qualifier for `public`, so the schema group is optional.
54
+ */
55
+ const CREATE_VIEW = /^CREATE\s+(?:MATERIALIZED\s+)?VIEW\s+(?:"([^"]+)"\.)?"([^"]+)"/i;
56
+
57
+ /** `schema.name`, the identity used in messages and in reference matching. */
58
+ export const viewKey = (view: { readonly schema: string; readonly name: string }): string =>
59
+ `${view.schema}.${view.name}`;
60
+
61
+ /** Every `CREATE VIEW` in a statement list, as {@link ViewDefinition}s. */
62
+ export const parseViewDefinitions = (
63
+ statements: ReadonlyArray<string>,
64
+ ): ReadonlyArray<ViewDefinition> =>
65
+ statements.flatMap((statement) => {
66
+ const match = CREATE_VIEW.exec(statement.trim());
67
+ const name = match?.[2];
68
+ return name === undefined ? [] : [{ schema: match?.[1] ?? "public", name, sql: statement }];
69
+ });
70
+
71
+ /**
72
+ * Names come out of quoted identifiers, so they may contain regex
73
+ * metacharacters; escape them rather than let one change the pattern.
74
+ */
75
+ const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
76
+
77
+ /**
78
+ * Whether `definition` selects from `target`.
79
+ *
80
+ * Matches both spellings a definition can carry: the quoted,
81
+ * schema-qualified form Drizzle's query builder emits (`"app"."work_view"`),
82
+ * and the bare form a hand-written `sql.raw` FROM clause may use
83
+ * (`app.work_view`), guarded so it cannot fire on a longer identifier ending
84
+ * the same way. A derived table aliased with a view's *unqualified* name is
85
+ * deliberately not a match — it names no schema, so references no view.
86
+ * The one blind spot that leaves: a hand-written FROM naming a view
87
+ * unqualified and leaning on `search_path`.
88
+ */
89
+ export const referencesView = (definition: ViewDefinition, target: ViewDefinition): boolean => {
90
+ if (viewKey(definition) === viewKey(target)) return false;
91
+ if (definition.sql.includes(`"${target.schema}"."${target.name}"`)) return true;
92
+ const qualified = `${escapeRegExp(target.schema)}\\.${escapeRegExp(target.name)}`;
93
+ return new RegExp(`(^|[^\\w."])${qualified}\\b`).test(definition.sql);
94
+ };
95
+
96
+ /** Every view-on-view edge among `views`. Empty is the passing state. */
97
+ export const findViewEdges = (views: ReadonlyArray<ViewDefinition>): ReadonlyArray<ViewEdge> =>
98
+ views.flatMap((from) =>
99
+ views.filter((to) => referencesView(from, to)).map((to) => ({ from, to })),
100
+ );
101
+
102
+ /** The schema has at least one view-on-view edge. */
103
+ export class ViewDependencyError extends Schema.TaggedError<ViewDependencyError>()(
104
+ "ViewDependencyError",
105
+ {
106
+ edges: Schema.Array(Schema.Struct({ from: Schema.String, to: Schema.String })),
107
+ },
108
+ ) {
109
+ override get message(): string {
110
+ const grouped = Arr.groupBy(this.edges, (edge) => edge.from);
111
+ const listed = Arr.sort(
112
+ Record.toEntries(grouped).map(
113
+ ([from, group]) =>
114
+ ` ${from} selects from ${Arr.sort(
115
+ group.map((edge) => edge.to),
116
+ Order.String,
117
+ ).join(", ")}`,
118
+ ),
119
+ Order.String,
120
+ ).join("\n");
121
+ return (
122
+ `${this.edges.length} view-on-view dependency(ies) in the schema:\n\n${listed}\n\n` +
123
+ "drizzle-kit emits DROP VIEW / CREATE VIEW with no topological ordering and no cascade to " +
124
+ "unchanged dependents, so a migration touching either end of one of these edges produces a " +
125
+ "drop order PostgreSQL rejects, fixable only by hand-editing the migration.\n" +
126
+ "Fix: resolve the dependency against the underlying tables instead of the sibling view."
127
+ );
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Generate the whole schema from an empty snapshot — every view exactly as a
133
+ * migration would create it — and assert no definition references another
134
+ * view. Succeeds with the number of views checked.
135
+ *
136
+ * @example
137
+ * ```ts
138
+ * // An application's `db:view-dependencies` task passes its own schema module.
139
+ * import { checkViewDependencies } from "@thomasfosterau/effect-drizzle-ext-pg/tooling";
140
+ * import { integer, pgTable, pgView, text } from "drizzle-orm/pg-core";
141
+ * import * as Effect from "effect/Effect";
142
+ *
143
+ * const article = pgTable("article", { id: integer().primaryKey(), title: text().notNull() });
144
+ * const articleTitles = pgView("article_titles").as((qb) =>
145
+ * qb.select({ title: article.title }).from(article),
146
+ * );
147
+ *
148
+ * const { viewCount } = await Effect.runPromise(
149
+ * checkViewDependencies({ schema: { article, articleTitles } }),
150
+ * );
151
+ * console.log(viewCount); // 1
152
+ * ```
153
+ */
154
+ export const checkViewDependencies = (options: {
155
+ readonly schema: Record<string, unknown>;
156
+ }): Effect.Effect<{ readonly viewCount: number }, ViewDependencyError | SchemaDiffError> =>
157
+ Effect.gen(function* () {
158
+ const cur = yield* drizzleKit(() => generateDrizzleJson(options.schema));
159
+ const empty = yield* drizzleKit(() => generateDrizzleJson({}));
160
+ const views = parseViewDefinitions(yield* drizzleKit(() => generateMigration(empty, cur)));
161
+ const edges = findViewEdges(views);
162
+ if (Arr.isReadonlyArrayNonEmpty(edges)) {
163
+ return yield* new ViewDependencyError({
164
+ edges: edges.map((edge) => ({ from: viewKey(edge.from), to: viewKey(edge.to) })),
165
+ });
166
+ }
167
+ return { viewCount: views.length };
168
+ });