@thomasfosterau/effect-drizzle-ext-pg 0.0.0 → 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.
- package/LICENSE +21 -0
- package/README.md +442 -2
- package/dist/index.d.ts +159 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +109 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations-Dp67FrXh.d.ts +68 -0
- package/dist/migrations-Dp67FrXh.d.ts.map +1 -0
- package/dist/migrations.d.ts +2 -0
- package/dist/migrations.js +136 -0
- package/dist/migrations.js.map +1 -0
- package/dist/testing.d.ts +270 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +408 -0
- package/dist/testing.js.map +1 -0
- package/dist/tooling/index.d.ts +217 -0
- package/dist/tooling/index.d.ts.map +1 -0
- package/dist/tooling/index.js +479 -0
- package/dist/tooling/index.js.map +1 -0
- package/package.json +76 -4
- package/src/index.ts +231 -0
- package/src/migrations.ts +173 -0
- package/src/provisioning.ts +407 -0
- package/src/testing.ts +447 -0
- package/src/tooling/index.ts +46 -0
- package/src/tooling/migration-drift.ts +640 -0
- package/src/tooling/view-dependencies.ts +168 -0
|
@@ -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
|
+
});
|