better-supabase 0.3.0 → 0.4.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/README.md +7 -6
- package/bin/better-supabase.js +4 -0
- package/dist/{base64-CQeB1eSf.js → base64-D2gsioCY.js} +2 -2
- package/dist/claims-BAf6c7is.js +48 -0
- package/dist/cli/_format-Cax5EsM9.js +101 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +48 -0
- package/dist/cli/command-CK14fP91.js +502 -0
- package/dist/cli/compare-BmIWVEui.js +9 -0
- package/dist/cli/config-D9R0o1LH.js +1895 -0
- package/dist/cli/config-SSKSTUl2.js +2 -0
- package/dist/cli/db-DOBPt660.js +52 -0
- package/dist/cli/dist-B1MvLv-M.js +129 -0
- package/dist/cli/dist-CFgGknmZ.js +1112 -0
- package/dist/cli/dist-Du7VnEUG.js +621 -0
- package/dist/cli/doctor-BOym3mbL.js +2382 -0
- package/dist/cli/env-BZ0uy8XK.js +108 -0
- package/dist/cli/env-jIFUADLY.js +511 -0
- package/dist/cli/errors-4v4DTtda.js +56 -0
- package/dist/cli/fastest-levenshtein-CFN-lNDv.js +120 -0
- package/dist/cli/gen-Ccn_p90L.js +2008 -0
- package/dist/cli/gen-D5cLCULx.js +2 -0
- package/dist/cli/index.d.ts +493 -0
- package/dist/cli/index.js +184 -0
- package/dist/cli/init-ClnxRYBF.js +550 -0
- package/dist/cli/introspect-DxqfkVIv.js +81 -0
- package/dist/cli/io-k41_z-jT.js +24 -0
- package/dist/cli/json5-ChRycquj.js +787 -0
- package/dist/cli/jsonc-BUniUpOk.js +2 -0
- package/dist/cli/jsonc-D5Fu67jw.js +45 -0
- package/dist/cli/keys-ByKHnvP9.js +83 -0
- package/dist/cli/ohash-D7bG_EOb.js +277 -0
- package/dist/cli/openapi-8WHjxiTJ.js +66 -0
- package/dist/cli/project-B_GeXyHh.js +70 -0
- package/dist/cli/prompts-BNcNfAve.js +1142 -0
- package/dist/cli/prompts-BTYQGunc.js +2 -0
- package/dist/cli/read-sets-CMa29t9a.js +971 -0
- package/dist/cli/rolldown-runtime-BPOCksWG.js +24 -0
- package/dist/cli/run-CP20LKk_.js +347 -0
- package/dist/cli/seed-5J6eu_Xg.js +98 -0
- package/dist/cli/skills-LJJIhLFR.js +137 -0
- package/dist/cli/sql-D4VbqmXi.js +199 -0
- package/dist/cli/style-C0NFdJRL.js +29 -0
- package/dist/cli/toml-DB-MNnFs.js +424 -0
- package/dist/cli/version-5mW_8sY1.js +4 -0
- package/dist/cli/yaml-CYCbJ6et.js +2252 -0
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +13 -13
- package/dist/{compiler-2zqGzxVZ.d.ts → compiler-DB3aSFOA.d.ts} +2 -2
- package/dist/config/index.js +4 -4
- package/dist/{config-Be4GwsqI.js → config-BpxDfnbg.js} +1 -1
- package/dist/{config-DOsGYSiT.js → config-CbvFmGD8.js} +1 -1
- package/dist/{define-DF341GDj.js → define-Bh0AqTik.js} +160 -79
- package/dist/{define-DUbydmJa.d.ts → define-CRS2MRV3.d.ts} +32 -28
- package/dist/edge/index.d.ts +8 -12
- package/dist/edge/index.js +50 -24
- package/dist/{entitlements-DvJXoI02.d.ts → entitlements-Dm-FHpNH.d.ts} +2 -2
- package/dist/{errors-Bl3rkOzo.js → errors-L0xK41y_.js} +1 -1
- package/dist/events/index.d.ts +1 -1
- package/dist/events/index.js +4 -4
- package/dist/{events-Do8jXh4D.d.ts → events-B21UOhvP.d.ts} +4 -4
- package/dist/{executor-DynmIWhq.d.ts → executor-B5IoUxLE.d.ts} +1 -1
- package/dist/{executor-C57jx2pn.js → executor-CVt6dD0B.js} +3 -3
- package/dist/{executor-B26lgbHD.d.ts → executor-CZ7ImRiD.d.ts} +2 -2
- package/dist/hono/index.d.ts +16 -23
- package/dist/hono/index.js +10 -10
- package/dist/{index-CTTMS6_1.d.ts → index-528Ugmyp.d.ts} +12 -3
- package/dist/{index-CADlNHK-.d.ts → index-BAy4tsAP.d.ts} +5 -5
- package/dist/{index-BNsABq7P.d.ts → index-BBdvo-5d.d.ts} +23 -25
- package/dist/{index-qriRM9cZ.d.ts → index-BhHEC04r.d.ts} +3 -3
- package/dist/{index-GV77NpRP.d.ts → index-D5IfUIDh.d.ts} +3 -3
- package/dist/{index-CriFFJ_p.d.ts → index-DFmOpBGC.d.ts} +11 -11
- package/dist/{index-UJ-LUxtI.d.ts → index-RlLXd8dL.d.ts} +12 -12
- package/dist/index.d.ts +15 -15
- package/dist/index.js +13 -13
- package/dist/{invalidate-DbxuL2jL.js → invalidate-CgxmRamI.js} +7 -1
- package/dist/jobs/index.d.ts +10 -4
- package/dist/jobs/index.js +32 -16
- package/dist/{json-schema--aOXAGfU.js → json-schema-DP6V7cbr.js} +1 -1
- package/dist/list/index.d.ts +1 -1
- package/dist/list/index.js +3 -3
- package/dist/{live-QE3UPbGm.d.ts → live-BHakSWWo.d.ts} +12 -12
- package/dist/{live-D8rV1Ldw.js → live-CdJBOc4f.js} +13 -9
- package/dist/lookup-BaP5bI27.js +18 -0
- package/dist/mcp/index.d.ts +18 -18
- package/dist/mcp/index.js +23 -18
- package/dist/{mfa-x3V3vGh-.d.ts → mfa-C7MNQ1Ob.d.ts} +1 -1
- package/dist/{mfa-BxoR1RkN.js → mfa-byfjruR9.js} +118 -29
- package/dist/next/image/index.js +6 -3
- package/dist/next/index.d.ts +38 -35
- package/dist/next/index.js +41 -33
- package/dist/openapi/index.d.ts +1 -1
- package/dist/openapi/index.js +6 -6
- package/dist/orpc/index.d.ts +17 -20
- package/dist/orpc/index.js +11 -11
- package/dist/otel/index.d.ts +5 -5
- package/dist/otel/index.js +81 -62
- package/dist/{permdock-sql-Bta_G0v-.js → permdock-sql-C4KFzLc9.js} +1 -1
- package/dist/{plugin-DVRB8qNE.d.ts → plugin-bNhvJwRh.d.ts} +2 -2
- package/dist/plugins/actor/index.d.ts +1 -1
- package/dist/plugins/actor/index.js +1 -1
- package/dist/plugins/rules/index.d.ts +2 -2
- package/dist/plugins/rules/index.js +42 -12
- package/dist/plugins/soft-delete/index.d.ts +2 -2
- package/dist/plugins/soft-delete/index.js +2 -2
- package/dist/plugins/tenant/index.d.ts +2 -2
- package/dist/plugins/tenant/index.js +10 -4
- package/dist/plugins/timestamps/index.d.ts +1 -1
- package/dist/plugins/timestamps/index.js +1 -1
- package/dist/plugins/validation/index.d.ts +2 -2
- package/dist/plugins/validation/index.js +3 -3
- package/dist/{pool-B9qsLyFR.d.ts → pool-DFwEQwmN.d.ts} +11 -4
- package/dist/postgres/index.d.ts +5 -5
- package/dist/postgres/index.js +3 -2
- package/dist/{postgrest-DmBlevh-.d.ts → postgrest-Bc-0Xw62.d.ts} +1 -1
- package/dist/{problem-CGt1ADix.js → problem-BihjPR0t.js} +1 -1
- package/dist/{problem-Z2RTQjZD.d.ts → problem-U8UEKccv.d.ts} +1 -1
- package/dist/query/index.d.ts +2 -2
- package/dist/query/index.js +2 -2
- package/dist/{query-BdKLTPlj.js → query-BMMSWbI7.js} +18 -18
- package/dist/react/index.d.ts +6 -6
- package/dist/react/index.js +1 -1
- package/dist/react/server.d.ts +5 -5
- package/dist/react/server.js +3 -3
- package/dist/react/session.d.ts +4 -4
- package/dist/react/session.js +2 -2
- package/dist/{react-DNBYNazu.js → react-CQHMaESR.js} +39 -39
- package/dist/{read-set-D0qBcboM.js → read-set-FWK8qQ58.js} +50 -15
- package/dist/realtime/index.d.ts +1 -1
- package/dist/realtime/index.js +13 -12
- package/dist/{index-kpgopLNv.d.ts → refresh-BwYkyNOQ.d.ts} +6 -3
- package/dist/{resolve-BRciX2jK.d.ts → resolve-BAqQcwMW.d.ts} +15 -10
- package/dist/{resource-CwVgiI5q.d.ts → resource-BJcaIfxi.d.ts} +5 -5
- package/dist/{resource-D30rp0GN.js → resource-My_y-Syh.js} +6 -6
- package/dist/{respond-DAkPIdTH.d.ts → respond-DgIbdrt8.d.ts} +31 -12
- package/dist/{result-DWXatkd6.js → result-Bj6yt33D.js} +2 -2
- package/dist/{result-c2OYgCV2.d.ts → result-CqEsbgYs.d.ts} +3 -3
- package/dist/server/index.d.ts +14 -14
- package/dist/server/index.js +12 -12
- package/dist/{respond-C44EZAJo.js → server-BA75B0mT.js} +139 -100
- package/dist/{shared-_6-uPL7E.js → shared-DnoCdMDr.js} +4 -3
- package/dist/{simplify-LakqK1vH.js → simplify-Cqvfr9dj.js} +67 -24
- package/dist/sql/index.d.ts +1 -1
- package/dist/sql/index.js +7 -7
- package/dist/{sql-BIgk12ew.js → sql-B34HU2DI.js} +3 -2
- package/dist/ssr/index.d.ts +4 -4
- package/dist/ssr/index.js +2 -2
- package/dist/{standard-FUi3Z3-7.js → standard-BRyBGTtp.js} +2 -2
- package/dist/{standard-eudBiRk1.d.ts → standard-BsaCYQJc.d.ts} +1 -1
- package/dist/{stats-D78UQ8Yu.js → stats-C_YxA2r1.js} +33 -8
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.js +26 -8
- package/dist/{tables-Dhha3BXM.js → tables-Cb-3_VWc.js} +14 -0
- package/dist/{temporal-required-CMxeSVPo.js → temporal-required-CBNHT6ni.js} +1 -1
- package/dist/testing/index.d.ts +20 -21
- package/dist/testing/index.js +45 -45
- package/dist/{version-BfpG2gks.js → version-Bmf7_FzM.js} +1 -1
- package/dist/{view-3Po_PVlr.d.ts → view-B64o81wp.d.ts} +5 -5
- package/dist/{view-wZ-9qIUz.js → view-BrJI2LKp.js} +1 -1
- package/dist/webhooks/index.d.ts +3 -3
- package/dist/webhooks/index.js +1 -1
- package/dist/{webhooks-CAIFQSTM.js → webhooks-Bh_Jf_-l.js} +37 -18
- package/package.json +36 -4
- package/skills/better-supabase/SKILL.md +57 -10
- package/skills/better-supabase/references/plugins.md +7 -11
- package/skills/better-supabase/references/troubleshooting.md +24 -4
- package/skills/better-supabase-api/SKILL.md +66 -18
- package/skills/better-supabase-api/references/adapters.md +44 -26
- package/skills/better-supabase-auth/SKILL.md +146 -0
- package/skills/better-supabase-auth/references/permdock.md +55 -0
- package/skills/better-supabase-testing/SKILL.md +58 -4
- package/dist/claims-CoTobNC-.js +0 -25
|
@@ -0,0 +1,2008 @@
|
|
|
1
|
+
import { r as defineCliCommand } from "./command-CK14fP91.js";
|
|
2
|
+
import { n as CliError } from "./errors-4v4DTtda.js";
|
|
3
|
+
import { n as importPath, r as writeIfChanged, t as display } from "./io-k41_z-jT.js";
|
|
4
|
+
import { r as painter } from "./style-C0NFdJRL.js";
|
|
5
|
+
import { t as VERSION } from "./version-5mW_8sY1.js";
|
|
6
|
+
import { d as readSupabaseToml, s as stdinDatabaseUrl, t as databaseUrl, u as pgFunctionHooks } from "./config-D9R0o1LH.js";
|
|
7
|
+
import { c as readPermdock, l as fileDiff, n as entitlementHelpers, r as entitlementsMode, t as compiledReadSets } from "./read-sets-CMa29t9a.js";
|
|
8
|
+
import { t as byCodePoint } from "./compare-BmIWVEui.js";
|
|
9
|
+
import { n as withSpinner } from "./prompts-BNcNfAve.js";
|
|
10
|
+
import { t as connect } from "./db-DOBPt660.js";
|
|
11
|
+
import { basename, dirname, resolve } from "node:path";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
14
|
+
import { setTimeout } from "node:timers/promises";
|
|
15
|
+
import { kitLayout, permdockKeys, renderKit, sameKitFile } from "better-supabase/sql";
|
|
16
|
+
import { DEFAULT_CLAIMS, tenantClaimPaths } from "better-supabase/config";
|
|
17
|
+
import { toCamel, toSnake } from "better-supabase";
|
|
18
|
+
import { generateTypescript, pgTypeToTsType, sortGeneratorMetadata } from "@supabase/postgrest-typegen/generation";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
//#region src/cli/introspect/catalog.ts
|
|
21
|
+
const byName = (a, b) => a.schema === b.schema ? a.name < b.name ? -1 : a.name > b.name ? 1 : 0 : a.schema < b.schema ? -1 : 1;
|
|
22
|
+
function toColumn(column) {
|
|
23
|
+
const isArray = column.data_type === "ARRAY";
|
|
24
|
+
const udt = isArray ? column.format.replace(/^_/, "") : column.format;
|
|
25
|
+
const defaultValue = column.default_value === null || column.default_value === void 0 ? null : String(column.default_value);
|
|
26
|
+
return {
|
|
27
|
+
name: column.name,
|
|
28
|
+
udt,
|
|
29
|
+
format: column.format,
|
|
30
|
+
typeSchema: column.type_schema,
|
|
31
|
+
isArray,
|
|
32
|
+
isEnum: column.enums.length > 0,
|
|
33
|
+
nullable: column.is_nullable,
|
|
34
|
+
hasDefault: defaultValue !== null || column.identity_generation !== null || column.is_generated,
|
|
35
|
+
default: defaultValue,
|
|
36
|
+
identity: column.identity_generation === "ALWAYS" ? "always" : column.identity_generation === "BY DEFAULT" ? "by default" : null,
|
|
37
|
+
generated: column.is_generated,
|
|
38
|
+
updatable: column.is_updatable,
|
|
39
|
+
comment: column.comment
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function toFunction(fn, typeName, relationName) {
|
|
43
|
+
const args = [];
|
|
44
|
+
const table = [];
|
|
45
|
+
fn.args.forEach((arg, index) => {
|
|
46
|
+
const raw = typeName(arg.type_id) ?? "unknown";
|
|
47
|
+
const name = arg.name || `arg${index + 1}`;
|
|
48
|
+
if (arg.mode === "in" || arg.mode === "inout" || arg.mode === "variadic") {
|
|
49
|
+
const isArray = raw.startsWith("_");
|
|
50
|
+
args.push({
|
|
51
|
+
name,
|
|
52
|
+
udt: isArray ? raw.slice(1) : raw,
|
|
53
|
+
isArray,
|
|
54
|
+
hasDefault: arg.has_default === true
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (arg.mode === "table" || arg.mode === "out" || arg.mode === "inout") table.push({
|
|
58
|
+
name,
|
|
59
|
+
udt: raw
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
const searchPath = fn.config_params?.["search_path"];
|
|
63
|
+
return {
|
|
64
|
+
schema: fn.schema,
|
|
65
|
+
name: fn.name,
|
|
66
|
+
signature: fn.identity_argument_types,
|
|
67
|
+
args,
|
|
68
|
+
returnsTable: fn.args.some((arg) => arg.mode === "table") ? table : null,
|
|
69
|
+
returns: typeName(fn.return_type_id) ?? fn.return_type,
|
|
70
|
+
returnsRelation: relationName(fn.return_type_relation_id),
|
|
71
|
+
returnsSet: fn.is_set_returning_function,
|
|
72
|
+
volatility: fn.behavior.toLowerCase(),
|
|
73
|
+
securityDefiner: fn.security_definer,
|
|
74
|
+
language: fn.language,
|
|
75
|
+
searchPath: searchPath === void 0 ? null : searchPath.replace(/^""$/, "")
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Joins the typegen metadata and the extras into one record per object. */
|
|
79
|
+
function toCatalog(snapshot) {
|
|
80
|
+
const meta = snapshot.generator;
|
|
81
|
+
const extras = new Map(snapshot.extras.tables.map((table) => [table.id, table]));
|
|
82
|
+
const columns = /* @__PURE__ */ new Map();
|
|
83
|
+
for (const column of meta.columns) {
|
|
84
|
+
const list = columns.get(column.table_id);
|
|
85
|
+
if (list) list.push(column);
|
|
86
|
+
else columns.set(column.table_id, [column]);
|
|
87
|
+
}
|
|
88
|
+
const primaryKeys = /* @__PURE__ */ new Map();
|
|
89
|
+
for (const key of meta.primaryKeys) {
|
|
90
|
+
const list = primaryKeys.get(key.table_id);
|
|
91
|
+
if (list) list.push(key.name);
|
|
92
|
+
else primaryKeys.set(key.table_id, [key.name]);
|
|
93
|
+
}
|
|
94
|
+
const relationNames = /* @__PURE__ */ new Map();
|
|
95
|
+
for (const list of [
|
|
96
|
+
meta.tables,
|
|
97
|
+
meta.foreignTables,
|
|
98
|
+
meta.views,
|
|
99
|
+
meta.materializedViews
|
|
100
|
+
]) for (const entry of list) relationNames.set(entry.id, `${entry.schema}.${entry.name}`);
|
|
101
|
+
const foreignKeysOf = (schema, name, extra) => {
|
|
102
|
+
const actions = new Map((extra?.foreignKeys ?? []).map((fk) => [fk.name, fk]));
|
|
103
|
+
return meta.relationships.filter((rel) => rel.schema === schema && rel.relation === name && actions.has(rel.foreign_key_name)).map((rel) => {
|
|
104
|
+
const action = actions.get(rel.foreign_key_name);
|
|
105
|
+
return {
|
|
106
|
+
name: rel.foreign_key_name,
|
|
107
|
+
columns: rel.columns,
|
|
108
|
+
refSchema: rel.referenced_schema,
|
|
109
|
+
refTable: rel.referenced_relation,
|
|
110
|
+
refColumns: rel.referenced_columns,
|
|
111
|
+
oneToOne: rel.is_one_to_one,
|
|
112
|
+
onDelete: action?.onDelete ?? "no action",
|
|
113
|
+
onUpdate: action?.onUpdate ?? "no action"
|
|
114
|
+
};
|
|
115
|
+
}).sort((a, b) => byCodePoint(a.name, b.name));
|
|
116
|
+
};
|
|
117
|
+
const build = (entry, flags) => {
|
|
118
|
+
const extra = extras.get(entry.id);
|
|
119
|
+
const own = [...columns.get(entry.id) ?? []].sort((a, b) => a.ordinal_position - b.ordinal_position);
|
|
120
|
+
return {
|
|
121
|
+
id: entry.id,
|
|
122
|
+
schema: entry.schema,
|
|
123
|
+
name: entry.name,
|
|
124
|
+
comment: entry.comment,
|
|
125
|
+
...flags,
|
|
126
|
+
columns: own.map(toColumn),
|
|
127
|
+
primaryKey: extra && extra.primaryKey.length > 0 ? extra.primaryKey : primaryKeys.get(entry.id) ?? [],
|
|
128
|
+
uniques: extra?.uniques ?? [],
|
|
129
|
+
foreignKeys: foreignKeysOf(entry.schema, entry.name, extra),
|
|
130
|
+
checks: extra?.checks ?? [],
|
|
131
|
+
indexes: extra?.indexes ?? [],
|
|
132
|
+
policies: extra?.policies ?? [],
|
|
133
|
+
triggers: extra?.triggers ?? [],
|
|
134
|
+
grants: extra?.grants ?? [],
|
|
135
|
+
...extra?.columnGrants ? { columnGrants: extra.columnGrants } : {}
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
const tables = [
|
|
139
|
+
...meta.tables.map((table) => build(table, {
|
|
140
|
+
kind: "table",
|
|
141
|
+
rls: table.rls_enabled,
|
|
142
|
+
forceRls: table.rls_forced,
|
|
143
|
+
replicaIdentity: table.replica_identity,
|
|
144
|
+
insertable: true,
|
|
145
|
+
updatable: true
|
|
146
|
+
})),
|
|
147
|
+
...meta.foreignTables.map((table) => build(table, {
|
|
148
|
+
kind: "table",
|
|
149
|
+
rls: false,
|
|
150
|
+
forceRls: false,
|
|
151
|
+
replicaIdentity: null,
|
|
152
|
+
insertable: true,
|
|
153
|
+
updatable: true
|
|
154
|
+
})),
|
|
155
|
+
...meta.views.map((view) => build(view, {
|
|
156
|
+
kind: "view",
|
|
157
|
+
rls: false,
|
|
158
|
+
forceRls: false,
|
|
159
|
+
replicaIdentity: null,
|
|
160
|
+
insertable: view.is_insert_enabled ?? view.is_updatable,
|
|
161
|
+
updatable: view.is_update_enabled ?? view.is_updatable
|
|
162
|
+
})),
|
|
163
|
+
...meta.materializedViews.map((view) => build(view, {
|
|
164
|
+
kind: "view",
|
|
165
|
+
rls: false,
|
|
166
|
+
forceRls: false,
|
|
167
|
+
replicaIdentity: null,
|
|
168
|
+
insertable: false,
|
|
169
|
+
updatable: false
|
|
170
|
+
}))
|
|
171
|
+
].sort(byName);
|
|
172
|
+
const included = new Set(snapshot.schemas);
|
|
173
|
+
const enums = meta.types.filter((type) => type.enums.length > 0 && included.has(type.schema)).map((type) => ({
|
|
174
|
+
schema: type.schema,
|
|
175
|
+
name: type.name,
|
|
176
|
+
values: type.enums
|
|
177
|
+
})).sort(byName);
|
|
178
|
+
const typeNames = new Map(meta.types.map((type) => [type.id, type.name]));
|
|
179
|
+
const functions = meta.functions.map((fn) => toFunction(fn, (id) => typeNames.get(id), (id) => id === null ? null : relationNames.get(id) ?? null)).sort((a, b) => byName(a, b) || byCodePoint(a.signature, b.signature));
|
|
180
|
+
return {
|
|
181
|
+
schemas: snapshot.schemas,
|
|
182
|
+
tables,
|
|
183
|
+
enums,
|
|
184
|
+
functions,
|
|
185
|
+
buckets: snapshot.extras.buckets,
|
|
186
|
+
realtime: snapshot.extras.realtime
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
//#endregion
|
|
190
|
+
//#region src/cli/doctor/shared.ts
|
|
191
|
+
const catalogs = /* @__PURE__ */ new WeakMap();
|
|
192
|
+
/** The snapshot joined per table and function, computed once per snapshot. */
|
|
193
|
+
function catalogOf(context) {
|
|
194
|
+
let catalog = catalogs.get(context.snapshot);
|
|
195
|
+
if (!catalog) {
|
|
196
|
+
catalog = toCatalog(context.snapshot);
|
|
197
|
+
catalogs.set(context.snapshot, catalog);
|
|
198
|
+
}
|
|
199
|
+
return catalog;
|
|
200
|
+
}
|
|
201
|
+
const exposed = (context) => catalogOf(context).tables.filter((table) => context.config.schemas.includes(table.schema));
|
|
202
|
+
const qualified = (table) => `${table.schema}.${table.name}`;
|
|
203
|
+
const tableObject = (table) => ({
|
|
204
|
+
kind: "table",
|
|
205
|
+
schema: table.schema,
|
|
206
|
+
name: table.name
|
|
207
|
+
});
|
|
208
|
+
const policyObject = (table, policy) => ({
|
|
209
|
+
kind: "policy",
|
|
210
|
+
schema: table.schema,
|
|
211
|
+
name: policy.name
|
|
212
|
+
});
|
|
213
|
+
const splitTexts = /* @__PURE__ */ new Map();
|
|
214
|
+
const SPLIT_CACHE_SIZE = 64;
|
|
215
|
+
/** `text` split into lines; doctor locates many objects in the same SQL files. */
|
|
216
|
+
function linesOf(text) {
|
|
217
|
+
let lines = splitTexts.get(text);
|
|
218
|
+
if (!lines) {
|
|
219
|
+
if (splitTexts.size >= SPLIT_CACHE_SIZE) splitTexts.clear();
|
|
220
|
+
lines = text.split("\n");
|
|
221
|
+
splitTexts.set(text, lines);
|
|
222
|
+
}
|
|
223
|
+
return lines;
|
|
224
|
+
}
|
|
225
|
+
/** Line number (1-based) of the first line matching `pattern`. */
|
|
226
|
+
function lineOf(text, pattern) {
|
|
227
|
+
const index = linesOf(text).findIndex((line) => pattern.test(line));
|
|
228
|
+
return index === -1 ? void 0 : index + 1;
|
|
229
|
+
}
|
|
230
|
+
//#endregion
|
|
231
|
+
//#region src/cli/doctor/permdock.ts
|
|
232
|
+
/** Tables whose policies PermDock's helpers can't row-check (PermDock's PD037 scans the same ones). */
|
|
233
|
+
const HELPER_TABLES = /* @__PURE__ */ new Set(["storage.objects", "realtime.messages"]);
|
|
234
|
+
const POLICY = /create\s+policy\s+("(?:[^"]|"")+"|[\w$]+)\s+on\s+((?:"[^"]+"|\w+)\s*\.\s*(?:"[^"]+"|\w+))([\s\S]*?);/gi;
|
|
235
|
+
const HELPER_KEY = /\b(?:permitted_\w+_ids|permdock_has)"?\s*\(\s*'((?:[^']|'')*)'/g;
|
|
236
|
+
const unquote = (name) => name.replaceAll(/^"|"$/g, "").replaceAll("\"\"", "\"");
|
|
237
|
+
/**
|
|
238
|
+
* Policies better-supabase generates (`bs_` names) on `storage.objects` and
|
|
239
|
+
* `realtime.messages` that call PermDock's helpers, with the keys they pass.
|
|
240
|
+
* The first file that declares a policy wins, matching the `sqlFiles` order
|
|
241
|
+
* (declarative schemas, then the newest migration).
|
|
242
|
+
*/
|
|
243
|
+
function helperPolicies(files) {
|
|
244
|
+
const seen = /* @__PURE__ */ new Set();
|
|
245
|
+
const found = [];
|
|
246
|
+
for (const file of files) for (const match of file.text.matchAll(POLICY)) {
|
|
247
|
+
const name = unquote(match[1]);
|
|
248
|
+
const table = match[2].split(".").map((part) => unquote(part.trim())).join(".");
|
|
249
|
+
if (!HELPER_TABLES.has(table) || !name.startsWith("bs_")) continue;
|
|
250
|
+
const id = `${table}.${name}`;
|
|
251
|
+
if (seen.has(id)) continue;
|
|
252
|
+
seen.add(id);
|
|
253
|
+
const keys = [...match[3].matchAll(HELPER_KEY)].map((key) => key[1].replaceAll("''", "'"));
|
|
254
|
+
if (keys.length === 0) continue;
|
|
255
|
+
found.push({
|
|
256
|
+
name,
|
|
257
|
+
table,
|
|
258
|
+
keys: [...new Set(keys)],
|
|
259
|
+
file: file.path,
|
|
260
|
+
line: file.text.slice(0, match.index).split("\n").length
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return found;
|
|
264
|
+
}
|
|
265
|
+
/** `buckets` entries with a PermDock policy, and the keys they name. */
|
|
266
|
+
function configuredPermdockKeys(context) {
|
|
267
|
+
return Object.entries(context.config.buckets).flatMap(([bucket, config]) => config.policy !== void 0 && typeof config.policy !== "string" ? [{
|
|
268
|
+
bucket,
|
|
269
|
+
keys: permdockKeys(config.policy)
|
|
270
|
+
}] : []);
|
|
271
|
+
}
|
|
272
|
+
/** PermDock's helpers for the `entitlements` kit module, when the manifest allows PermDock mode. */
|
|
273
|
+
function entitlementsKit(context) {
|
|
274
|
+
const mode = entitlementsMode(context.config, context.permdock);
|
|
275
|
+
return mode.kind === "permdock" ? mode.permdock : void 0;
|
|
276
|
+
}
|
|
277
|
+
const PERMDOCK_RULES = [{
|
|
278
|
+
code: "BS214",
|
|
279
|
+
severity: "error",
|
|
280
|
+
title: "PermDock permission with row conditions in a Storage or Realtime policy",
|
|
281
|
+
description: "PermDock's SQL helpers (`permitted_<scope>_ids`, `permdock_has`) check role and scope, not row conditions. A bucket or topic policy that names a permission whose catalog entry has `rowConditions: true` grants every object or topic in the scope. Use PermDock's generated policies for those permissions.",
|
|
282
|
+
check: (context) => {
|
|
283
|
+
const project = context.permdock;
|
|
284
|
+
if (!project) return [];
|
|
285
|
+
const fromConfig = configuredPermdockKeys(context);
|
|
286
|
+
const fromSql = helperPolicies(context.sqlFiles ?? []);
|
|
287
|
+
const findings = project.problems.filter((problem) => problem.startsWith(project.catalogPath)).map((problem) => ({
|
|
288
|
+
severity: "info",
|
|
289
|
+
message: `Could not read PermDock's catalog: ${problem}`,
|
|
290
|
+
target: project.catalogPath
|
|
291
|
+
}));
|
|
292
|
+
const rowConditions = project.rowConditions;
|
|
293
|
+
if (!rowConditions) {
|
|
294
|
+
if (findings.length === 0 && (fromConfig.length > 0 || fromSql.length > 0)) findings.push({
|
|
295
|
+
severity: "info",
|
|
296
|
+
message: `Storage or Realtime policies call PermDock's helpers, but there is no ${project.catalogPath} to check their permissions against. Run \`permdock catalog\`, or set permdock.catalog in the config.`,
|
|
297
|
+
target: project.catalogPath
|
|
298
|
+
});
|
|
299
|
+
return findings;
|
|
300
|
+
}
|
|
301
|
+
const fix = "Use the policies `permdock rls generate` writes for it, or a permission whose catalog entry has rowConditions: false.";
|
|
302
|
+
for (const { bucket, keys } of fromConfig) for (const key of keys.filter((candidate) => rowConditions.has(candidate))) findings.push({
|
|
303
|
+
message: `buckets.${bucket} uses PermDock permission "${key}", which has row conditions in ${project.catalogPath}. The bucket policy would grant every object in the scope. ${fix}`,
|
|
304
|
+
target: `buckets.${bucket}:${key}`
|
|
305
|
+
});
|
|
306
|
+
for (const policy of fromSql) for (const key of policy.keys.filter((candidate) => rowConditions.has(candidate))) findings.push({
|
|
307
|
+
message: `Policy "${policy.name}" on ${policy.table} passes "${key}" to PermDock's helpers, but it has row conditions in ${project.catalogPath}. ${fix}`,
|
|
308
|
+
target: `${policy.table}.${policy.name}:${key}`,
|
|
309
|
+
location: {
|
|
310
|
+
file: policy.file,
|
|
311
|
+
line: policy.line
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
return findings;
|
|
315
|
+
}
|
|
316
|
+
}, {
|
|
317
|
+
code: "BS408",
|
|
318
|
+
severity: "warning",
|
|
319
|
+
title: "PermDock helpers the entitlements module calls are missing",
|
|
320
|
+
description: "With a PermDock manifest, the `entitlements` kit module reads memberships from PermDock's `member_<scope>_ids()` (in `has_entitlement`) and `member_<scope>_ids_for(uuid)` (in `feature_claims`). Doctor warns when `entitlements.permdock.scope` is not one of the manifest's scopes, when the manifest's `rls.helpers` lacks a helper (run `permdock rls generate` with a current PermDock), or when the snapshot lacks it (apply the migration it wrote).",
|
|
321
|
+
check: (context) => {
|
|
322
|
+
if (!context.config.sql.kit.includes("entitlements")) return [];
|
|
323
|
+
const mode = entitlementsMode(context.config, context.permdock);
|
|
324
|
+
if (mode.kind === "tenant") return [];
|
|
325
|
+
const manifest = context.config.permdock.manifest;
|
|
326
|
+
if (mode.kind === "invalid") return [{
|
|
327
|
+
message: mode.problem,
|
|
328
|
+
target: "entitlements.permdock"
|
|
329
|
+
}];
|
|
330
|
+
const listed = new Set((context.permdock?.manifest?.rls?.helpers ?? []).map((helper) => `${mode.permdock.schema}.${helper.name}`));
|
|
331
|
+
const read = context.snapshot.schemas.includes(mode.permdock.schema);
|
|
332
|
+
const present = new Set(catalogOf(context).functions.map((fn) => `${fn.schema}.${fn.name}`));
|
|
333
|
+
return entitlementHelpers(mode.permdock).flatMap((helper) => {
|
|
334
|
+
if (!listed.has(helper)) return [{
|
|
335
|
+
message: `${manifest} lists no ${helper}, which the entitlements module calls. Run \`permdock rls generate\` (PermDock writes member_<scope>_ids and member_<scope>_ids_for for every scope), then \`permdock supabase inspect --out\`.`,
|
|
336
|
+
target: helper
|
|
337
|
+
}];
|
|
338
|
+
if (read && !present.has(helper)) return [{
|
|
339
|
+
message: `${helper} is in ${manifest} but not in the database. Apply the migration \`permdock rls generate\` wrote before the entitlements module.`,
|
|
340
|
+
target: helper
|
|
341
|
+
}];
|
|
342
|
+
return [];
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}];
|
|
346
|
+
//#endregion
|
|
347
|
+
//#region src/cli/gen/shared.ts
|
|
348
|
+
const q = (value) => JSON.stringify(value);
|
|
349
|
+
/** A property key: bare when it is a valid identifier, quoted otherwise. */
|
|
350
|
+
function prop(name) {
|
|
351
|
+
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : q(name);
|
|
352
|
+
}
|
|
353
|
+
function nullable(type, isNullable) {
|
|
354
|
+
return isNullable ? `${type} | null` : type;
|
|
355
|
+
}
|
|
356
|
+
/** `T[]`, parenthesized when `T` is a union or other compound type. */
|
|
357
|
+
function arrayOf(type) {
|
|
358
|
+
return /^[\w.$]+$/.test(type) ? `${type}[]` : `(${type})[]`;
|
|
359
|
+
}
|
|
360
|
+
function indent(lines, depth) {
|
|
361
|
+
const pad = " ".repeat(depth);
|
|
362
|
+
return lines.flatMap((line) => line.split("\n")).map((line) => line === "" ? line : pad + line);
|
|
363
|
+
}
|
|
364
|
+
/** `name: {` + indented children + `};`, or an inline empty type. */
|
|
365
|
+
function block(name, children, empty = "{ [_ in never]: never }") {
|
|
366
|
+
if (children.length === 0) return [`${name}: ${empty};`];
|
|
367
|
+
return [
|
|
368
|
+
`${name}: {`,
|
|
369
|
+
...indent(children, 1),
|
|
370
|
+
"};"
|
|
371
|
+
];
|
|
372
|
+
}
|
|
373
|
+
function sameColumns(left, right) {
|
|
374
|
+
return left.length === right.length && left.every((column) => right.includes(column));
|
|
375
|
+
}
|
|
376
|
+
function singular(name) {
|
|
377
|
+
if (name.endsWith("ies")) return `${name.slice(0, -3)}y`;
|
|
378
|
+
if (name.endsWith("sses")) return name.slice(0, -2);
|
|
379
|
+
if (name.endsWith("s") && !name.endsWith("ss")) return name.slice(0, -1);
|
|
380
|
+
return name;
|
|
381
|
+
}
|
|
382
|
+
function pascal(name) {
|
|
383
|
+
return name.replaceAll(/(^|[_-]+)([a-z0-9])/g, (_, __, char) => char.toUpperCase()).replace(/^[a-z]/, (char) => char.toUpperCase());
|
|
384
|
+
}
|
|
385
|
+
function isJsonUdt(udt) {
|
|
386
|
+
return udt === "json" || udt === "jsonb";
|
|
387
|
+
}
|
|
388
|
+
const CHECK_ANY = /^CHECK \(\(\(?"?(\w+)"?\)?(?:::\w+)? = ANY \(\(?ARRAY\[(.+)\]\)?(?:::\w+\[\])?\)\)\)$/;
|
|
389
|
+
const CHECK_OR = /^CHECK \(\((.+)\)\)$/;
|
|
390
|
+
const OR_TERM = /^\(?"?(\w+)"?\)?(?:::\w+)? = '((?:[^']|'')*)'(?:::\w+)?\)?$/;
|
|
391
|
+
const LITERAL = /'((?:[^']|'')*)'(?:::[\w ]+)?/g;
|
|
392
|
+
/**
|
|
393
|
+
* Extracts `column in ('a', 'b')` CHECK constraints, which Postgres stores as
|
|
394
|
+
* `CHECK ((col = ANY (ARRAY['a'::text, 'b'::text])))` or an OR chain.
|
|
395
|
+
*/
|
|
396
|
+
function parseCheckUnion(definition) {
|
|
397
|
+
const any = CHECK_ANY.exec(definition);
|
|
398
|
+
if (any?.[1] && any[2]) {
|
|
399
|
+
const values = [...any[2].matchAll(LITERAL)].map((match) => (match[1] ?? "").replaceAll("''", "'"));
|
|
400
|
+
return values.length > 0 ? {
|
|
401
|
+
column: any[1],
|
|
402
|
+
values
|
|
403
|
+
} : void 0;
|
|
404
|
+
}
|
|
405
|
+
const chain = CHECK_OR.exec(definition);
|
|
406
|
+
if (chain?.[1]) {
|
|
407
|
+
const terms = chain[1].split(/\) OR \(|\s+OR\s+/);
|
|
408
|
+
let column;
|
|
409
|
+
const values = [];
|
|
410
|
+
for (const term of terms) {
|
|
411
|
+
const match = OR_TERM.exec(term.trim());
|
|
412
|
+
if (!match?.[1]) return void 0;
|
|
413
|
+
if (column && column !== match[1]) return void 0;
|
|
414
|
+
column = match[1];
|
|
415
|
+
values.push((match[2] ?? "").replaceAll("''", "'"));
|
|
416
|
+
}
|
|
417
|
+
return column && values.length > 1 ? {
|
|
418
|
+
column,
|
|
419
|
+
values
|
|
420
|
+
} : void 0;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
//#endregion
|
|
424
|
+
//#region src/cli/gen/emit.ts
|
|
425
|
+
const GENERATED_HEADER = "// @generated by better-supabase gen. Do not edit; run `better-supabase gen` instead.";
|
|
426
|
+
function rowEntries(columns, name) {
|
|
427
|
+
return columns.map((column) => `${prop(column[name])}: ${nullable(column.tsType, column.nullable)};`);
|
|
428
|
+
}
|
|
429
|
+
function insertEntries(columns, name) {
|
|
430
|
+
return columns.map((column) => {
|
|
431
|
+
if (!column.insertable) return `${prop(column[name])}?: never;`;
|
|
432
|
+
const optional = column.optional ? "?" : "";
|
|
433
|
+
return `${prop(column[name])}${optional}: ${nullable(column.tsType, column.nullable)};`;
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
function updateEntries(columns, name) {
|
|
437
|
+
return columns.map((column) => {
|
|
438
|
+
if (!column.updatable) return `${prop(column[name])}?: never;`;
|
|
439
|
+
return `${prop(column[name])}?: ${nullable(column.tsType, column.nullable)};`;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
function argEntries(fn) {
|
|
443
|
+
return fn.args.map((arg) => `${prop(arg.name)}${arg.optional ? "?" : ""}: ${arg.tsType};`);
|
|
444
|
+
}
|
|
445
|
+
function enrichedDatabase(model) {
|
|
446
|
+
const schemas = model.config.schemas.flatMap((schema) => {
|
|
447
|
+
const tables = model.tables.filter((t) => t.snapshot.schema === schema).flatMap((table) => {
|
|
448
|
+
const overrides = table.columns.filter((column) => column.values !== void 0 && !column.snapshot.isEnum || column.json && column.tsType !== "Json" || column.storage !== void 0).map((column) => `${prop(column.db)}: ${column.tsType};`);
|
|
449
|
+
return overrides.length > 0 ? block(prop(table.snapshot.name), overrides) : [];
|
|
450
|
+
});
|
|
451
|
+
return tables.length > 0 ? block(prop(schema), tables) : [];
|
|
452
|
+
});
|
|
453
|
+
if (schemas.length === 0) return ["export type Database = SupabaseDatabase;"];
|
|
454
|
+
return [
|
|
455
|
+
"export type Database = EnrichDatabase<",
|
|
456
|
+
" SupabaseDatabase,",
|
|
457
|
+
" {",
|
|
458
|
+
...indent(schemas, 2),
|
|
459
|
+
" }",
|
|
460
|
+
">;"
|
|
461
|
+
];
|
|
462
|
+
}
|
|
463
|
+
function modelsType(model) {
|
|
464
|
+
return [
|
|
465
|
+
"export type Models = {",
|
|
466
|
+
...indent(model.tables.flatMap((table) => {
|
|
467
|
+
const relations = table.relations.map((relation) => `${prop(relation.name)}: { table: ${q(relation.meta.table)}; kind: ${q(relation.meta.kind)}; nullable: ${String(relation.meta.nullable)} };`);
|
|
468
|
+
const pk = table.meta.primaryKey;
|
|
469
|
+
const uniques = Object.entries(table.meta.uniqueKeys).map(([name, columns]) => `${prop(name)}: readonly [${columns.map(q).join(", ")}];`);
|
|
470
|
+
const flags = [];
|
|
471
|
+
const tableFlags = table.meta.flags;
|
|
472
|
+
if (tableFlags.softDelete) flags.push(`softDelete: ${q(tableFlags.softDelete)};`);
|
|
473
|
+
if (tableFlags.timestamps) flags.push("timestamps: true;");
|
|
474
|
+
if (tableFlags.tenant) flags.push(`tenant: ${q(tableFlags.tenant)};`);
|
|
475
|
+
if (tableFlags.actor) flags.push("actor: true;");
|
|
476
|
+
const none = "Record<never, never>";
|
|
477
|
+
const union = (names) => names.length === 0 ? "never" : names.map(q).join(" | ");
|
|
478
|
+
return block(prop(table.key), [
|
|
479
|
+
...block("Row", rowEntries(table.columns, "app"), none),
|
|
480
|
+
...table.snapshot.insertable ? block("Insert", insertEntries(table.columns, "app"), none) : ["Insert: never;"],
|
|
481
|
+
...table.snapshot.updatable ? block("Update", updateEntries(table.columns, "app"), none) : ["Update: never;"],
|
|
482
|
+
...block("Relations", relations, none),
|
|
483
|
+
`PrimaryKey: ${union(pk)};`,
|
|
484
|
+
...block("UniqueKeys", uniques, none),
|
|
485
|
+
`Checks: ${union(table.snapshot.checks.map((check) => check.name))};`,
|
|
486
|
+
`ForeignKeys: ${union(table.snapshot.foreignKeys.map((fk) => fk.name))};`,
|
|
487
|
+
...block("Flags", flags, none)
|
|
488
|
+
]);
|
|
489
|
+
}), 1),
|
|
490
|
+
"};"
|
|
491
|
+
];
|
|
492
|
+
}
|
|
493
|
+
function functionsType(model) {
|
|
494
|
+
if (model.functions.length === 0) return ["export type Functions = Record<never, never>;"];
|
|
495
|
+
return [
|
|
496
|
+
"export type Functions = {",
|
|
497
|
+
...indent(model.functions.flatMap((fn) => block(prop(fn.key), [...block("Args", argEntries(fn), "Record<never, never>"), `Returns: ${fn.returns};`])), 1),
|
|
498
|
+
"};"
|
|
499
|
+
];
|
|
500
|
+
}
|
|
501
|
+
/** The metadata module next to `generated.ts`: `generated.meta.js` and its `.d.ts`. */
|
|
502
|
+
function metaPaths(output) {
|
|
503
|
+
const base = output.replace(/\.[cm]?[jt]sx?$/, "");
|
|
504
|
+
return {
|
|
505
|
+
js: `${base}.meta.js`,
|
|
506
|
+
dts: `${base}.meta.d.ts`
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* The schema metadata as plain JavaScript plus a one-line declaration, so
|
|
511
|
+
* TypeScript reads `SchemaMeta` instead of checking thousands of lines of
|
|
512
|
+
* object literal in every program that imports the generated module.
|
|
513
|
+
*/
|
|
514
|
+
function emitMeta(model, options = {}) {
|
|
515
|
+
const runtime = options.runtimeImport ?? "better-supabase";
|
|
516
|
+
return {
|
|
517
|
+
js: [
|
|
518
|
+
GENERATED_HEADER,
|
|
519
|
+
`// @ts-self-types="${options.types ?? "./generated.meta.d.ts"}"`,
|
|
520
|
+
"/* oxlint-disable */",
|
|
521
|
+
"/* eslint-disable */",
|
|
522
|
+
"",
|
|
523
|
+
`export default ${JSON.stringify(model.meta, null, 2)};`,
|
|
524
|
+
""
|
|
525
|
+
].join("\n"),
|
|
526
|
+
dts: [
|
|
527
|
+
GENERATED_HEADER,
|
|
528
|
+
"/* oxlint-disable */",
|
|
529
|
+
"/* eslint-disable */",
|
|
530
|
+
"",
|
|
531
|
+
`import type { SchemaMeta } from ${q(runtime)};`,
|
|
532
|
+
"",
|
|
533
|
+
"declare const meta: SchemaMeta;",
|
|
534
|
+
"export default meta;",
|
|
535
|
+
""
|
|
536
|
+
].join("\n")
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
/** Renders the generated module. */
|
|
540
|
+
function emitModule(model, options) {
|
|
541
|
+
const runtime = options.runtimeImport ?? "better-supabase";
|
|
542
|
+
const lines = [
|
|
543
|
+
GENERATED_HEADER,
|
|
544
|
+
"/* oxlint-disable */",
|
|
545
|
+
"/* eslint-disable */",
|
|
546
|
+
"",
|
|
547
|
+
`import { defineSchema, type Schema } from ${q(runtime)};`
|
|
548
|
+
];
|
|
549
|
+
const storagePaths = model.tables.some((table) => table.columns.some((column) => column.storage !== void 0));
|
|
550
|
+
lines.push(`import type { EnrichDatabase${storagePaths ? ", StoragePath" : ""} } from ${q(runtime)};`, `import type { Database as SupabaseDatabase } from ${q(options.databaseTypesImport ?? "./database.types.ts")};`);
|
|
551
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
552
|
+
for (const entry of model.jsonImports) {
|
|
553
|
+
const list = byFile.get(entry.from) ?? [];
|
|
554
|
+
list.push(entry.name);
|
|
555
|
+
byFile.set(entry.from, list);
|
|
556
|
+
}
|
|
557
|
+
for (const [from, names] of [...byFile.entries()].sort(([a], [b]) => byCodePoint(a, b))) lines.push(`import type { ${names.sort().join(", ")} } from ${q(options.importPathFor(from))};`);
|
|
558
|
+
lines.push(`import meta from ${q(options.metaImport ?? "./generated.meta.js")};`, "", "export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];", "", ...enrichedDatabase(model), "");
|
|
559
|
+
for (const entry of model.enums) lines.push(`export const ${entry.constant} = [${entry.values.map(q).join(", ")}] as const;`, `export type ${pascal(entry.name)} = (typeof ${entry.constant})[number];`);
|
|
560
|
+
for (const table of model.tables) for (const column of table.columns) if (column.values && !column.snapshot.isEnum) {
|
|
561
|
+
const constant = `${table.key}${pascal(column.app)}Values`.replace(/^[A-Z]/, (c) => c.toLowerCase());
|
|
562
|
+
lines.push(`export const ${constant} = [${column.values.map(q).join(", ")}] as const;`, `export type ${pascal(table.key)}${pascal(column.app)} = (typeof ${constant})[number];`);
|
|
563
|
+
}
|
|
564
|
+
if (model.enums.length > 0) lines.push("");
|
|
565
|
+
lines.push(...modelsType(model), "", ...functionsType(model), "", "export type TableName = keyof Models;", "/** Constraint names for `isConflict`, `isCheck` and `isForeignKey`. */", "export type UniqueConstraint = { [T in TableName]: Extract<keyof Models[T]['UniqueKeys'], string> }[TableName];", "export type CheckConstraint = Models[TableName]['Checks'];", "export type ForeignKeyConstraint = Models[TableName]['ForeignKeys'];", "export type RowOf<T extends TableName> = Models[T]['Row'];", "export type InsertOf<T extends TableName> = Models[T]['Insert'];", "export type UpdateOf<T extends TableName> = Models[T]['Update'];", "");
|
|
566
|
+
if (model.meta.buckets) lines.push("/** Bucket configs for `defineBucket` from `better-supabase/storage`. */", `export const buckets = ${JSON.stringify(model.meta.buckets, null, 2)} as const;`, "");
|
|
567
|
+
if (model.meta.topics) lines.push("/** Topic templates for `defineTopic` from `better-supabase/realtime`. */", `export const topics = ${JSON.stringify(model.meta.topics, null, 2)} as const;`, "");
|
|
568
|
+
lines.push("export const schema: Schema<Models, Database, Functions> = defineSchema(meta);", "");
|
|
569
|
+
return lines.join("\n");
|
|
570
|
+
}
|
|
571
|
+
//#endregion
|
|
572
|
+
//#region src/cli/introspect/typegen.ts
|
|
573
|
+
/**
|
|
574
|
+
* The package root and `/introspection` build their arktype schemas on
|
|
575
|
+
* import (about 90 ms), so they load when a command reads or validates
|
|
576
|
+
* metadata, not for `--help`.
|
|
577
|
+
*/
|
|
578
|
+
const metadataModule = () => import("@supabase/postgrest-typegen");
|
|
579
|
+
const introspectionModule = () => import("@supabase/postgrest-typegen/introspection");
|
|
580
|
+
/** Introspects `schemas`, keeping only the types they reference. */
|
|
581
|
+
async function readGeneratorMetadata(db, schemas) {
|
|
582
|
+
const { introspect } = await introspectionModule();
|
|
583
|
+
return pruneTypes(await introspect(db, { includedSchemas: [...schemas] }), schemas);
|
|
584
|
+
}
|
|
585
|
+
/** Postgres assigns oids below this to built-in objects; those are stable. */
|
|
586
|
+
const FIRST_NORMAL_OID = 16384;
|
|
587
|
+
/**
|
|
588
|
+
* Replaces the oids of user objects with ids derived from their names and
|
|
589
|
+
* zeroes size estimates, so a snapshot of the same schema is byte-identical
|
|
590
|
+
* after `supabase db reset` or on another machine. Returns the id mapping
|
|
591
|
+
* for data keyed by the old oids.
|
|
592
|
+
*/
|
|
593
|
+
function stabilizeMetadata(metadata) {
|
|
594
|
+
const ids = /* @__PURE__ */ new Map();
|
|
595
|
+
const assign = (base, entries) => {
|
|
596
|
+
[...entries].sort((a, b) => byCodePoint(a.key, b.key)).forEach((entry, index) => {
|
|
597
|
+
if (entry.id >= FIRST_NORMAL_OID) ids.set(entry.id, base + index);
|
|
598
|
+
});
|
|
599
|
+
};
|
|
600
|
+
assign(1e6, [
|
|
601
|
+
...metadata.tables,
|
|
602
|
+
...metadata.foreignTables,
|
|
603
|
+
...metadata.views,
|
|
604
|
+
...metadata.materializedViews
|
|
605
|
+
].map((entry) => ({
|
|
606
|
+
id: entry.id,
|
|
607
|
+
key: `${entry.schema}.${entry.name}`
|
|
608
|
+
})));
|
|
609
|
+
assign(2e6, metadata.types.map((entry) => ({
|
|
610
|
+
id: entry.id,
|
|
611
|
+
key: `${entry.schema}.${entry.name}`
|
|
612
|
+
})));
|
|
613
|
+
assign(3e6, metadata.functions.map((entry) => ({
|
|
614
|
+
id: entry.id,
|
|
615
|
+
key: `${entry.schema}.${entry.name}(${entry.identity_argument_types})`
|
|
616
|
+
})));
|
|
617
|
+
assign(4e6, metadata.schemas.map((entry) => ({
|
|
618
|
+
id: entry.id,
|
|
619
|
+
key: entry.name
|
|
620
|
+
})));
|
|
621
|
+
const map = (id) => ids.get(id) ?? id;
|
|
622
|
+
const mapNullable = (id) => id === null ? null : map(id);
|
|
623
|
+
const stable = {
|
|
624
|
+
...metadata,
|
|
625
|
+
schemas: metadata.schemas.map((entry) => ({
|
|
626
|
+
...entry,
|
|
627
|
+
id: map(entry.id)
|
|
628
|
+
})),
|
|
629
|
+
tables: metadata.tables.map((entry) => ({
|
|
630
|
+
...entry,
|
|
631
|
+
id: map(entry.id),
|
|
632
|
+
bytes: 0,
|
|
633
|
+
size: "0 bytes",
|
|
634
|
+
live_rows_estimate: 0,
|
|
635
|
+
dead_rows_estimate: 0
|
|
636
|
+
})),
|
|
637
|
+
foreignTables: metadata.foreignTables.map((entry) => ({
|
|
638
|
+
...entry,
|
|
639
|
+
id: map(entry.id)
|
|
640
|
+
})),
|
|
641
|
+
views: metadata.views.map((entry) => ({
|
|
642
|
+
...entry,
|
|
643
|
+
id: map(entry.id)
|
|
644
|
+
})),
|
|
645
|
+
materializedViews: metadata.materializedViews.map((entry) => ({
|
|
646
|
+
...entry,
|
|
647
|
+
id: map(entry.id)
|
|
648
|
+
})),
|
|
649
|
+
columns: metadata.columns.map((entry) => ({
|
|
650
|
+
...entry,
|
|
651
|
+
table_id: map(entry.table_id),
|
|
652
|
+
id: `${map(entry.table_id)}.${entry.ordinal_position}`
|
|
653
|
+
})),
|
|
654
|
+
primaryKeys: metadata.primaryKeys.map((entry) => ({
|
|
655
|
+
...entry,
|
|
656
|
+
table_id: map(entry.table_id)
|
|
657
|
+
})),
|
|
658
|
+
functions: metadata.functions.map((entry) => ({
|
|
659
|
+
...entry,
|
|
660
|
+
id: map(entry.id),
|
|
661
|
+
return_type_id: map(entry.return_type_id),
|
|
662
|
+
return_type_relation_id: mapNullable(entry.return_type_relation_id),
|
|
663
|
+
args: entry.args.map((arg) => ({
|
|
664
|
+
...arg,
|
|
665
|
+
type_id: map(arg.type_id)
|
|
666
|
+
}))
|
|
667
|
+
})),
|
|
668
|
+
types: metadata.types.map((entry) => ({
|
|
669
|
+
...entry,
|
|
670
|
+
id: map(entry.id),
|
|
671
|
+
type_relation_id: mapNullable(entry.type_relation_id),
|
|
672
|
+
attributes: entry.attributes.map((attribute) => ({
|
|
673
|
+
...attribute,
|
|
674
|
+
type_id: map(attribute.type_id)
|
|
675
|
+
}))
|
|
676
|
+
}))
|
|
677
|
+
};
|
|
678
|
+
return {
|
|
679
|
+
metadata: sortGeneratorMetadata(stable),
|
|
680
|
+
ids
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
/** Validates metadata loaded from a snapshot file. */
|
|
684
|
+
async function validateGeneratorMetadata(data) {
|
|
685
|
+
const { parseGeneratorMetadata } = await metadataModule();
|
|
686
|
+
return parseGeneratorMetadata(data);
|
|
687
|
+
}
|
|
688
|
+
/** The JSON document out-of-process generators read. */
|
|
689
|
+
async function serializeGenerator(metadata) {
|
|
690
|
+
const { serializeGeneratorMetadata } = await metadataModule();
|
|
691
|
+
return serializeGeneratorMetadata(sortGeneratorMetadata(metadata));
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Keeps only what belongs to `schemas`, so the Database type matches
|
|
695
|
+
* `supabase gen types --schema ...` for exactly those schemas.
|
|
696
|
+
*/
|
|
697
|
+
function restrictSchemas(metadata, schemas) {
|
|
698
|
+
const keep = new Set(schemas);
|
|
699
|
+
return {
|
|
700
|
+
...metadata,
|
|
701
|
+
schemas: metadata.schemas.filter((schema) => keep.has(schema.name)),
|
|
702
|
+
tables: metadata.tables.filter((table) => keep.has(table.schema)),
|
|
703
|
+
foreignTables: metadata.foreignTables.filter((table) => keep.has(table.schema)),
|
|
704
|
+
views: metadata.views.filter((view) => keep.has(view.schema)),
|
|
705
|
+
materializedViews: metadata.materializedViews.filter((view) => keep.has(view.schema)),
|
|
706
|
+
columns: metadata.columns.filter((column) => keep.has(column.schema)),
|
|
707
|
+
primaryKeys: metadata.primaryKeys.filter((key) => keep.has(key.schema)),
|
|
708
|
+
relationships: metadata.relationships.filter((relationship) => keep.has(relationship.schema)),
|
|
709
|
+
functions: metadata.functions.filter((fn) => keep.has(fn.schema))
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
let oxfmt;
|
|
713
|
+
/** oxfmt is an optional peer, loaded on demand; `undefined` when it is not installed. */
|
|
714
|
+
function loadOxfmt() {
|
|
715
|
+
oxfmt ??= import("oxfmt").catch(() => void 0);
|
|
716
|
+
return oxfmt;
|
|
717
|
+
}
|
|
718
|
+
/** Whether `database.types.ts` comes out formatted. */
|
|
719
|
+
async function oxfmtInstalled() {
|
|
720
|
+
return await loadOxfmt() !== void 0;
|
|
721
|
+
}
|
|
722
|
+
/** Formats like `supabase gen types`, or returns `code` as it is without oxfmt. */
|
|
723
|
+
async function formatTypes(code) {
|
|
724
|
+
const formatter = await loadOxfmt();
|
|
725
|
+
if (!formatter) return code;
|
|
726
|
+
const { code: formatted, errors } = await formatter.format("output.ts", code, {
|
|
727
|
+
semi: false,
|
|
728
|
+
printWidth: 80
|
|
729
|
+
});
|
|
730
|
+
if (errors.length > 0) throw new Error(`oxfmt failed to format database.types.ts: ${errors.map((error) => error.message).join("; ")}`);
|
|
731
|
+
return formatted;
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Renders `database.types.ts`, byte for byte what `supabase gen types` prints
|
|
735
|
+
* when oxfmt is installed.
|
|
736
|
+
*/
|
|
737
|
+
function generateDatabaseTypes(metadata, options) {
|
|
738
|
+
return generateTypescript(sortGeneratorMetadata(restrictSchemas(metadata, options.schemas)), {
|
|
739
|
+
postgrestVersion: options.postgrestVersion,
|
|
740
|
+
detectOneToOneRelationships: true,
|
|
741
|
+
format: formatTypes
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* TypeScript type for a column or argument, resolved exactly like the
|
|
746
|
+
* `Database` type does (domains, composites, enums in other schemas).
|
|
747
|
+
*/
|
|
748
|
+
function tsTypeOf(metadata, schema, format, typeSchema) {
|
|
749
|
+
const owner = metadata.schemas.find((entry) => entry.name === schema) ?? {
|
|
750
|
+
id: 0,
|
|
751
|
+
name: schema,
|
|
752
|
+
owner: ""
|
|
753
|
+
};
|
|
754
|
+
return pgTypeToTsType(owner, format, {
|
|
755
|
+
types: metadata.types,
|
|
756
|
+
schemas: metadata.schemas,
|
|
757
|
+
tables: metadata.tables,
|
|
758
|
+
views: metadata.views,
|
|
759
|
+
foreignTables: metadata.foreignTables,
|
|
760
|
+
materializedViews: metadata.materializedViews
|
|
761
|
+
}, typeSchema);
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* `introspect()` returns every type in the database (hundreds of built-ins).
|
|
765
|
+
* Keep the ones in the read schemas and those referenced by columns,
|
|
766
|
+
* functions and composite attributes; built-in scalars resolve by name.
|
|
767
|
+
*/
|
|
768
|
+
function pruneTypes(metadata, schemas) {
|
|
769
|
+
const byId = new Map(metadata.types.map((type) => [type.id, type]));
|
|
770
|
+
const byName = new Map(metadata.types.map((type) => [`${type.schema}.${type.name}`, type]));
|
|
771
|
+
const keep = /* @__PURE__ */ new Set();
|
|
772
|
+
const visit = (id) => {
|
|
773
|
+
if (id === null || id === void 0 || keep.has(id)) return;
|
|
774
|
+
const type = byId.get(id);
|
|
775
|
+
if (!type) return;
|
|
776
|
+
keep.add(id);
|
|
777
|
+
for (const attribute of type.attributes) visit(attribute.type_id);
|
|
778
|
+
};
|
|
779
|
+
for (const type of metadata.types) if (schemas.includes(type.schema)) visit(type.id);
|
|
780
|
+
for (const column of metadata.columns) {
|
|
781
|
+
visit(byName.get(`${column.type_schema}.${column.format}`)?.id);
|
|
782
|
+
visit(byName.get(`${column.type_schema}.${column.format.replace(/^_/, "")}`)?.id);
|
|
783
|
+
}
|
|
784
|
+
for (const fn of metadata.functions) {
|
|
785
|
+
visit(fn.return_type_id);
|
|
786
|
+
for (const arg of fn.args) visit(arg.type_id);
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
...metadata,
|
|
790
|
+
types: metadata.types.filter((type) => keep.has(type.id))
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
//#endregion
|
|
794
|
+
//#region src/cli/gen/model.ts
|
|
795
|
+
const applyCasing = (name, casing) => casing === "camel" ? toCamel(name) : name;
|
|
796
|
+
function tableKey(table, casing, schemas) {
|
|
797
|
+
const base = applyCasing(table.name, casing);
|
|
798
|
+
if (schemas.length === 1 || table.schema === "public") return base;
|
|
799
|
+
return applyCasing(`${table.schema}_${table.name}`, casing);
|
|
800
|
+
}
|
|
801
|
+
const CODEC_TYPE = {
|
|
802
|
+
instant: "Temporal.Instant",
|
|
803
|
+
plainDateTime: "Temporal.PlainDateTime",
|
|
804
|
+
bigint: "bigint",
|
|
805
|
+
string: "string"
|
|
806
|
+
};
|
|
807
|
+
function codecFor(udt, config) {
|
|
808
|
+
const { codecs } = config;
|
|
809
|
+
if (codecs.timestamptz === "instant") {
|
|
810
|
+
if (udt === "timestamptz") return "instant";
|
|
811
|
+
if (udt === "timestamp") return "plainDateTime";
|
|
812
|
+
}
|
|
813
|
+
if (udt === "int8" && codecs.int8 !== "number") return codecs.int8;
|
|
814
|
+
if (udt === "numeric" && codecs.numeric === "string") return "string";
|
|
815
|
+
}
|
|
816
|
+
const TEXT_UDTS = /* @__PURE__ */ new Set([
|
|
817
|
+
"text",
|
|
818
|
+
"varchar",
|
|
819
|
+
"bpchar"
|
|
820
|
+
]);
|
|
821
|
+
/** `config.storagePaths` resolved to bucket ids, with a check that every key is used. */
|
|
822
|
+
function storagePathsOf(config) {
|
|
823
|
+
const bucketId = (name) => {
|
|
824
|
+
const bucket = config.buckets[name];
|
|
825
|
+
if (!bucket) return name;
|
|
826
|
+
return bucket.id ?? name.replaceAll(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
|
|
827
|
+
};
|
|
828
|
+
const unused = new Set(Object.keys(config.storagePaths));
|
|
829
|
+
return {
|
|
830
|
+
lookup(schema, table, column, udt) {
|
|
831
|
+
const key = [`${schema}.${table}.${column}`, `${table}.${column}`].find((candidate) => candidate in config.storagePaths);
|
|
832
|
+
if (key === void 0) return;
|
|
833
|
+
unused.delete(key);
|
|
834
|
+
if (!TEXT_UDTS.has(udt)) throw new TypeError(`storagePaths["${key}"]: ${schema}.${table}.${column} is ${udt}, not a text column`);
|
|
835
|
+
return bucketId(config.storagePaths[key]);
|
|
836
|
+
},
|
|
837
|
+
assertUsed() {
|
|
838
|
+
const [first] = unused;
|
|
839
|
+
if (first !== void 0) throw new TypeError(`storagePaths["${first}"]: no such column. Use \`table.column\` or \`schema.table.column\` (database names) in \`schemas\`.`);
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
function enumType(values) {
|
|
844
|
+
return values.map((value) => JSON.stringify(value)).join(" | ");
|
|
845
|
+
}
|
|
846
|
+
/** Builds the codegen model from a snapshot and config. */
|
|
847
|
+
function buildModel(snapshot, config) {
|
|
848
|
+
const catalog = toCatalog(snapshot);
|
|
849
|
+
const introspection = restrictSchemas(snapshot.generator, config.schemas);
|
|
850
|
+
const tsType = (schema, format, typeSchema) => tsTypeOf(introspection, schema, format, typeSchema).replaceAll(/\(([\w.$]+)\)\[\]/g, "$1[]");
|
|
851
|
+
const enumsByName = /* @__PURE__ */ new Map();
|
|
852
|
+
for (const entry of catalog.enums) enumsByName.set(`${entry.schema}.${entry.name}`, entry.values);
|
|
853
|
+
const jsonImports = /* @__PURE__ */ new Map();
|
|
854
|
+
const jsonTypeFor = (table, column) => {
|
|
855
|
+
const override = config.json[`${table}.${column}`];
|
|
856
|
+
if (!override) return void 0;
|
|
857
|
+
if ("type" in override) return override.type;
|
|
858
|
+
const [from, name] = override.import.split("#");
|
|
859
|
+
if (!from || !name) throw new TypeError(`json["${table}.${column}"].import must look like "./path.ts#TypeName"`);
|
|
860
|
+
jsonImports.set(name, {
|
|
861
|
+
name,
|
|
862
|
+
from
|
|
863
|
+
});
|
|
864
|
+
return name;
|
|
865
|
+
};
|
|
866
|
+
const storagePaths = storagePathsOf(config);
|
|
867
|
+
const included = catalog.tables.filter((table) => config.schemas.includes(table.schema) && !config.tables[table.name]?.exclude);
|
|
868
|
+
const keyOf = /* @__PURE__ */ new Map();
|
|
869
|
+
for (const table of included) keyOf.set(`${table.schema}.${table.name}`, tableKey(table, config.casing, config.schemas));
|
|
870
|
+
const tables = included.map((table) => {
|
|
871
|
+
const casing = config.tables[table.name]?.casing ?? config.casing;
|
|
872
|
+
const checks = /* @__PURE__ */ new Map();
|
|
873
|
+
for (const check of table.checks) {
|
|
874
|
+
const union = parseCheckUnion(check.definition);
|
|
875
|
+
if (union) checks.set(union.column, union.values);
|
|
876
|
+
}
|
|
877
|
+
return {
|
|
878
|
+
table,
|
|
879
|
+
casing,
|
|
880
|
+
columns: table.columns.map((column) => {
|
|
881
|
+
const values = (column.isEnum ? enumsByName.get(`${column.typeSchema}.${column.udt}`) : void 0) ?? checks.get(column.name);
|
|
882
|
+
const json = isJsonUdt(column.udt);
|
|
883
|
+
const override = json ? jsonTypeFor(table.name, column.name) : void 0;
|
|
884
|
+
const storage = storagePaths.lookup(table.schema, table.name, column.name, column.udt);
|
|
885
|
+
const codec = override || values || storage ? void 0 : codecFor(column.udt, config);
|
|
886
|
+
const base = override ?? (values ? enumType(values) : storage ? `StoragePath<${JSON.stringify(storage)}>` : codec ? CODEC_TYPE[codec] : void 0);
|
|
887
|
+
const columnType = base === void 0 ? tsType(table.schema, column.format, column.typeSchema) : column.isArray ? arrayOf(base) : base;
|
|
888
|
+
const readonly = column.generated || column.identity === "always";
|
|
889
|
+
const insertable = !readonly && table.insertable;
|
|
890
|
+
const updatable = !readonly && table.updatable && column.updatable;
|
|
891
|
+
return {
|
|
892
|
+
app: applyCasing(column.name, casing),
|
|
893
|
+
db: column.name,
|
|
894
|
+
snapshot: column,
|
|
895
|
+
tsType: columnType,
|
|
896
|
+
nullable: column.nullable,
|
|
897
|
+
optional: column.nullable || column.hasDefault || readonly,
|
|
898
|
+
readonly,
|
|
899
|
+
values,
|
|
900
|
+
json,
|
|
901
|
+
codec,
|
|
902
|
+
storage,
|
|
903
|
+
insertable,
|
|
904
|
+
updatable
|
|
905
|
+
};
|
|
906
|
+
})
|
|
907
|
+
};
|
|
908
|
+
});
|
|
909
|
+
storagePaths.assertUsed();
|
|
910
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
911
|
+
for (const entry of tables) byKey.set(`${entry.table.schema}.${entry.table.name}`, entry);
|
|
912
|
+
const appName = (schema, table, column) => {
|
|
913
|
+
return byKey.get(`${schema}.${table}`)?.columns.find((col) => col.db === column)?.app ?? column;
|
|
914
|
+
};
|
|
915
|
+
const relations = /* @__PURE__ */ new Map();
|
|
916
|
+
const push = (tableId, relation) => {
|
|
917
|
+
const list = relations.get(tableId);
|
|
918
|
+
if (list) list.push(relation);
|
|
919
|
+
else relations.set(tableId, [relation]);
|
|
920
|
+
};
|
|
921
|
+
for (const { table, casing, columns } of tables) {
|
|
922
|
+
const sourceId = `${table.schema}.${table.name}`;
|
|
923
|
+
for (const fk of table.foreignKeys) {
|
|
924
|
+
const targetId = `${fk.refSchema}.${fk.refTable}`;
|
|
925
|
+
const target = byKey.get(targetId);
|
|
926
|
+
if (!target) continue;
|
|
927
|
+
const targetKey = keyOf.get(targetId) ?? fk.refTable;
|
|
928
|
+
const sourceColumns = fk.columns.map((column) => appName(table.schema, table.name, column));
|
|
929
|
+
const targetColumns = fk.refColumns.map((column) => appName(fk.refSchema, fk.refTable, column));
|
|
930
|
+
const nullable = fk.columns.some((column) => columns.find((col) => col.db === column)?.nullable ?? true);
|
|
931
|
+
const onDelete = fk.onDelete === "cascade" || fk.onDelete === "set null" || fk.onDelete === "set default" ? { onDelete: fk.onDelete } : {};
|
|
932
|
+
const naming = namingColumns(fk.columns, fk.refColumns);
|
|
933
|
+
const [only] = naming;
|
|
934
|
+
const forwardBase = naming.length === 1 && only?.endsWith("_id") ? only.slice(0, -3) : singular(fk.refTable);
|
|
935
|
+
push(sourceId, {
|
|
936
|
+
name: applyCasing(forwardBase, casing),
|
|
937
|
+
meta: {
|
|
938
|
+
table: targetKey,
|
|
939
|
+
kind: "one",
|
|
940
|
+
nullable,
|
|
941
|
+
foreignKey: fk.name,
|
|
942
|
+
columns: sourceColumns,
|
|
943
|
+
references: targetColumns,
|
|
944
|
+
direction: "forward",
|
|
945
|
+
...onDelete
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
const unique = fk.oneToOne || sameColumns(table.primaryKey, fk.columns) || table.uniques.some((key) => sameColumns(key.columns, fk.columns));
|
|
949
|
+
push(targetId, {
|
|
950
|
+
name: applyCasing(unique ? singular(table.name) : table.name, target.casing),
|
|
951
|
+
meta: {
|
|
952
|
+
table: keyOf.get(sourceId) ?? table.name,
|
|
953
|
+
kind: unique ? "one" : "many",
|
|
954
|
+
nullable: true,
|
|
955
|
+
foreignKey: fk.name,
|
|
956
|
+
columns: targetColumns,
|
|
957
|
+
references: sourceColumns,
|
|
958
|
+
direction: "reverse",
|
|
959
|
+
...onDelete
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
const flagsConfig = config.plugins;
|
|
965
|
+
const tableModels = tables.map(({ table, casing, columns }) => {
|
|
966
|
+
const id = `${table.schema}.${table.name}`;
|
|
967
|
+
const renames = config.tables[table.name]?.relations ?? {};
|
|
968
|
+
const columnNames = new Set(columns.map((column) => column.app));
|
|
969
|
+
const list = dedupeRelations(relations.get(id) ?? [], columnNames, casing).map((relation) => ({
|
|
970
|
+
...relation,
|
|
971
|
+
name: renames[relation.name] ?? relation.name
|
|
972
|
+
}));
|
|
973
|
+
const has = (db) => columns.find((col) => col.db === db)?.app;
|
|
974
|
+
const flags = {};
|
|
975
|
+
if (flagsConfig.softDelete) {
|
|
976
|
+
const column = has(flagsConfig.softDelete.column);
|
|
977
|
+
if (column) flags.softDelete = column;
|
|
978
|
+
}
|
|
979
|
+
if (flagsConfig.timestamps) {
|
|
980
|
+
const createdAt = has(flagsConfig.timestamps.createdAt);
|
|
981
|
+
const updatedAt = has(flagsConfig.timestamps.updatedAt);
|
|
982
|
+
if (createdAt || updatedAt) flags.timestamps = {
|
|
983
|
+
...createdAt ? { createdAt } : {},
|
|
984
|
+
...updatedAt ? { updatedAt } : {}
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
if (flagsConfig.tenant) {
|
|
988
|
+
const column = has(flagsConfig.tenant.column);
|
|
989
|
+
if (column) flags.tenant = column;
|
|
990
|
+
}
|
|
991
|
+
if (flagsConfig.actor) {
|
|
992
|
+
const createdBy = has(flagsConfig.actor.createdBy);
|
|
993
|
+
const updatedBy = has(flagsConfig.actor.updatedBy);
|
|
994
|
+
if (createdBy || updatedBy) flags.actor = {
|
|
995
|
+
...createdBy ? { createdBy } : {},
|
|
996
|
+
...updatedBy ? { updatedBy } : {}
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
const key = keyOf.get(id) ?? table.name;
|
|
1000
|
+
const columnMeta = {};
|
|
1001
|
+
for (const column of columns) {
|
|
1002
|
+
const meta = {
|
|
1003
|
+
db: column.db,
|
|
1004
|
+
type: column.snapshot.udt,
|
|
1005
|
+
nullable: column.nullable,
|
|
1006
|
+
hasDefault: column.snapshot.hasDefault
|
|
1007
|
+
};
|
|
1008
|
+
if (column.readonly) meta.generated = true;
|
|
1009
|
+
if (column.snapshot.identity) meta.identity = column.snapshot.identity;
|
|
1010
|
+
if (!column.readonly && !column.insertable) meta.insertable = false;
|
|
1011
|
+
if (!column.readonly && !column.updatable) meta.updatable = false;
|
|
1012
|
+
if (column.snapshot.isArray) meta.array = true;
|
|
1013
|
+
if (column.json) meta.json = true;
|
|
1014
|
+
if (column.values) meta.enum = column.values;
|
|
1015
|
+
if (column.codec) meta.codec = column.codec;
|
|
1016
|
+
if (column.storage) meta.storage = column.storage;
|
|
1017
|
+
if (config.sensitive.includes(`${table.name}.${column.db}`) || config.sensitive.includes(`${table.schema}.${table.name}.${column.db}`)) meta.sensitive = true;
|
|
1018
|
+
columnMeta[column.app] = meta;
|
|
1019
|
+
}
|
|
1020
|
+
const app = (db) => columns.find((col) => col.db === db)?.app ?? db;
|
|
1021
|
+
const uniqueKeys = {};
|
|
1022
|
+
for (const unique of table.uniques) uniqueKeys[unique.name] = unique.columns.map(app);
|
|
1023
|
+
const relationMeta = {};
|
|
1024
|
+
for (const relation of list) relationMeta[relation.name] = relation.meta;
|
|
1025
|
+
return {
|
|
1026
|
+
key,
|
|
1027
|
+
snapshot: table,
|
|
1028
|
+
casing,
|
|
1029
|
+
columns,
|
|
1030
|
+
relations: list,
|
|
1031
|
+
meta: {
|
|
1032
|
+
key,
|
|
1033
|
+
name: table.name,
|
|
1034
|
+
schema: table.schema,
|
|
1035
|
+
kind: table.kind,
|
|
1036
|
+
columns: columnMeta,
|
|
1037
|
+
primaryKey: table.primaryKey.map(app),
|
|
1038
|
+
uniqueKeys,
|
|
1039
|
+
relations: relationMeta,
|
|
1040
|
+
flags
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
});
|
|
1044
|
+
const enums = catalog.enums.filter((entry) => config.schemas.includes(entry.schema)).map((entry) => ({
|
|
1045
|
+
schema: entry.schema,
|
|
1046
|
+
name: entry.name,
|
|
1047
|
+
constant: `${toCamel(entry.name)}Values`,
|
|
1048
|
+
values: entry.values
|
|
1049
|
+
}));
|
|
1050
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1051
|
+
const functions = catalog.functions.filter((fn) => config.schemas.includes(fn.schema)).filter((fn) => fn.returns !== "trigger" && fn.returns !== "event_trigger").filter((fn) => !fn.args.some((arg) => arg.udt === "internal")).filter((fn) => {
|
|
1052
|
+
if (seen.has(fn.name)) return false;
|
|
1053
|
+
seen.add(fn.name);
|
|
1054
|
+
return true;
|
|
1055
|
+
}).map((fn) => {
|
|
1056
|
+
const typeOf = (format) => {
|
|
1057
|
+
const isArray = format.startsWith("_");
|
|
1058
|
+
const values = enumsByName.get(`${fn.schema}.${isArray ? format.slice(1) : format}`);
|
|
1059
|
+
if (values) return isArray ? arrayOf(enumType(values)) : enumType(values);
|
|
1060
|
+
return tsType(fn.schema, format);
|
|
1061
|
+
};
|
|
1062
|
+
let returns;
|
|
1063
|
+
if (fn.returnsTable) returns = `{ ${fn.returnsTable.map((column) => `${JSON.stringify(column.name)}: ${typeOf(column.udt)}`).join("; ")} }[]`;
|
|
1064
|
+
else {
|
|
1065
|
+
returns = typeOf(fn.returns);
|
|
1066
|
+
if (fn.returnsSet) returns = arrayOf(returns);
|
|
1067
|
+
}
|
|
1068
|
+
return {
|
|
1069
|
+
key: fn.name,
|
|
1070
|
+
meta: {
|
|
1071
|
+
name: fn.name,
|
|
1072
|
+
schema: fn.schema,
|
|
1073
|
+
args: fn.args.map((arg) => ({
|
|
1074
|
+
name: arg.name,
|
|
1075
|
+
type: arg.udt
|
|
1076
|
+
})),
|
|
1077
|
+
returns: fn.returns,
|
|
1078
|
+
returnsSet: fn.returnsSet,
|
|
1079
|
+
volatility: fn.volatility
|
|
1080
|
+
},
|
|
1081
|
+
args: fn.args.map((arg) => ({
|
|
1082
|
+
name: arg.name,
|
|
1083
|
+
tsType: typeOf(arg.isArray ? `_${arg.udt}` : arg.udt),
|
|
1084
|
+
optional: arg.hasDefault
|
|
1085
|
+
})),
|
|
1086
|
+
returns
|
|
1087
|
+
};
|
|
1088
|
+
});
|
|
1089
|
+
const tablesMeta = {};
|
|
1090
|
+
for (const table of tableModels) tablesMeta[table.key] = table.meta;
|
|
1091
|
+
const enumsMeta = {};
|
|
1092
|
+
for (const entry of enums) enumsMeta[entry.name] = entry.values;
|
|
1093
|
+
const functionsMeta = {};
|
|
1094
|
+
for (const fn of functions) functionsMeta[fn.key] = fn.meta;
|
|
1095
|
+
const claimOverrides = {};
|
|
1096
|
+
for (const key of Object.keys(DEFAULT_CLAIMS)) if (config.claims[key] !== DEFAULT_CLAIMS[key]) claimOverrides[key] = config.claims[key];
|
|
1097
|
+
const tenantClaim = config.claims.tenant === DEFAULT_CLAIMS.tenant ? void 0 : tenantClaimPaths(config.claims.tenant);
|
|
1098
|
+
const meta = {
|
|
1099
|
+
version: 1,
|
|
1100
|
+
casing: config.casing,
|
|
1101
|
+
tables: tablesMeta,
|
|
1102
|
+
enums: enumsMeta,
|
|
1103
|
+
functions: functionsMeta,
|
|
1104
|
+
...Object.keys(config.buckets).length > 0 ? { buckets: Object.fromEntries(Object.entries(config.buckets).map(([name, bucket]) => [name, {
|
|
1105
|
+
id: bucket.id ?? name.replaceAll(/[A-Z]/g, (char) => `-${char.toLowerCase()}`),
|
|
1106
|
+
public: bucket.public ?? false,
|
|
1107
|
+
path: bucket.path,
|
|
1108
|
+
...bucket.policy ? { policy: bucket.policy } : {},
|
|
1109
|
+
...bucket.policy === "tenant" && tenantClaim ? { tenant: { claim: tenantClaim } } : {},
|
|
1110
|
+
...bucket.fileSizeLimit ? { fileSizeLimit: bucket.fileSizeLimit } : {},
|
|
1111
|
+
...bucket.allowedMimeTypes ? { allowedMimeTypes: bucket.allowedMimeTypes } : {}
|
|
1112
|
+
}])) } : {},
|
|
1113
|
+
...Object.keys(config.topics).length > 0 ? { topics: config.topics } : {},
|
|
1114
|
+
...config.realtime.tables.length > 0 ? { realtime: realtimeMeta(config.realtime.tables, tableModels) } : {},
|
|
1115
|
+
...Object.keys(claimOverrides).length > 0 ? { claims: claimOverrides } : {}
|
|
1116
|
+
};
|
|
1117
|
+
return {
|
|
1118
|
+
tables: tableModels,
|
|
1119
|
+
enums,
|
|
1120
|
+
functions,
|
|
1121
|
+
jsonImports: [...jsonImports.values()],
|
|
1122
|
+
meta,
|
|
1123
|
+
config,
|
|
1124
|
+
catalog,
|
|
1125
|
+
introspection
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* The columns that name a relation. A composite key that repeats a column on
|
|
1130
|
+
* both sides, such as `(customer_id, organization_id)` referencing
|
|
1131
|
+
* `(id, organization_id)` to keep rows in one tenant, is named by the rest.
|
|
1132
|
+
*/
|
|
1133
|
+
function namingColumns(columns, references) {
|
|
1134
|
+
const own = columns.filter((column, index) => column !== references[index]);
|
|
1135
|
+
return own.length > 0 ? own : columns;
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Makes relation names unique per table and distinct from column names:
|
|
1139
|
+
* two FKs to the same table become `customersByPrimaryContact`-style names.
|
|
1140
|
+
*/
|
|
1141
|
+
function dedupeRelations(list, columns, casing) {
|
|
1142
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1143
|
+
for (const relation of list) counts.set(relation.name, (counts.get(relation.name) ?? 0) + 1);
|
|
1144
|
+
const used = /* @__PURE__ */ new Set();
|
|
1145
|
+
return list.map((relation) => {
|
|
1146
|
+
let name = relation.name;
|
|
1147
|
+
if ((counts.get(name) ?? 0) > 1 || columns.has(name)) {
|
|
1148
|
+
const { columns: from, references: to } = relation.meta;
|
|
1149
|
+
const suffix = (relation.meta.direction === "forward" ? namingColumns(from, to) : namingColumns(to, from)).map((column) => column.replace(/_?[iI]d$/, "")).join("_");
|
|
1150
|
+
name = applyCasing(`${casing === "camel" ? toSnake(name) : name}_by_${casing === "camel" ? toSnake(suffix) : suffix}`, casing);
|
|
1151
|
+
}
|
|
1152
|
+
while (used.has(name) || columns.has(name)) name = `${name}_`;
|
|
1153
|
+
used.add(name);
|
|
1154
|
+
return {
|
|
1155
|
+
...relation,
|
|
1156
|
+
name
|
|
1157
|
+
};
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
function realtimeMeta(names, tables) {
|
|
1161
|
+
const out = {};
|
|
1162
|
+
for (const name of names) {
|
|
1163
|
+
const table = tables.find((candidate) => candidate.meta.name === name || `${candidate.meta.schema}.${candidate.meta.name}` === name);
|
|
1164
|
+
if (!table) throw new Error(`realtime.tables: unknown table "${name}". Check the name and \`schemas\`.`);
|
|
1165
|
+
const tenant = table.meta.flags.tenant;
|
|
1166
|
+
out[table.key] = tenant ? { tenant } : {};
|
|
1167
|
+
}
|
|
1168
|
+
return out;
|
|
1169
|
+
}
|
|
1170
|
+
//#endregion
|
|
1171
|
+
//#region src/cli/introspect/extras.ts
|
|
1172
|
+
/**
|
|
1173
|
+
* Catalog details `@supabase/postgrest-typegen` leaves out: named unique and
|
|
1174
|
+
* CHECK constraints, foreign key actions, ordered primary keys, indexes,
|
|
1175
|
+
* policies, triggers, grants, buckets and the realtime publication.
|
|
1176
|
+
*
|
|
1177
|
+
* The SQL takes no bind parameters because the Management API endpoint only
|
|
1178
|
+
* accepts a query string; schema names are inlined as escaped literals.
|
|
1179
|
+
*/
|
|
1180
|
+
const literalArray = (values) => `array[${values.map((value) => `'${value.replaceAll("'", "''")}'`).join(", ")}]::text[]`;
|
|
1181
|
+
/** Row estimate from which a table counts as large in the snapshot. */
|
|
1182
|
+
const LARGE_TABLE_ROWS = 1e4;
|
|
1183
|
+
const RELATIONS = (schemas) => `
|
|
1184
|
+
select c.oid::int8 as id, n.nspname as schema, c.relname as name,
|
|
1185
|
+
c.reltuples >= ${LARGE_TABLE_ROWS} as large
|
|
1186
|
+
from pg_class c
|
|
1187
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
1188
|
+
where n.nspname = any(${schemas}) and c.relkind in ('r', 'p', 'v', 'm', 'f')
|
|
1189
|
+
and not c.relispartition
|
|
1190
|
+
order by 2, 3`;
|
|
1191
|
+
const CONSTRAINTS = (schemas) => `
|
|
1192
|
+
select con.conrelid::int8 as table_id, con.conname as name, con.contype as type,
|
|
1193
|
+
array(select a.attname from unnest(con.conkey) with ordinality k(attnum, ord)
|
|
1194
|
+
join pg_attribute a on a.attrelid = con.conrelid and a.attnum = k.attnum order by k.ord)::text[] as columns,
|
|
1195
|
+
con.confdeltype as on_delete, con.confupdtype as on_update,
|
|
1196
|
+
pg_get_constraintdef(con.oid) as definition
|
|
1197
|
+
from pg_constraint con
|
|
1198
|
+
join pg_class c on c.oid = con.conrelid
|
|
1199
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
1200
|
+
where n.nspname = any(${schemas}) and con.contype in ('p', 'u', 'f', 'c')
|
|
1201
|
+
order by 1, 2`;
|
|
1202
|
+
const INDEXES = (schemas) => `
|
|
1203
|
+
select ix.indrelid::int8 as table_id, i.relname as name,
|
|
1204
|
+
ix.indisunique as unique, ix.indisprimary as primary,
|
|
1205
|
+
(ix.indpred is not null or 0 = any(ix.indkey::int2[])) as partial,
|
|
1206
|
+
am.amname as method, pg_get_expr(ix.indpred, ix.indrelid) as predicate,
|
|
1207
|
+
array(select a.attname from unnest(ix.indkey::int2[]) with ordinality k(attnum, ord)
|
|
1208
|
+
join pg_attribute a on a.attrelid = ix.indrelid and a.attnum = k.attnum order by k.ord)::text[] as columns
|
|
1209
|
+
from pg_index ix
|
|
1210
|
+
join pg_class i on i.oid = ix.indexrelid
|
|
1211
|
+
join pg_am am on am.oid = i.relam
|
|
1212
|
+
join pg_class c on c.oid = ix.indrelid
|
|
1213
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
1214
|
+
where n.nspname = any(${schemas})
|
|
1215
|
+
order by 1, 2`;
|
|
1216
|
+
const POLICIES = (schemas) => `
|
|
1217
|
+
select c.oid::int8 as table_id, p.policyname as name,
|
|
1218
|
+
(p.permissive = 'PERMISSIVE') as permissive, p.roles::text[] as roles, lower(p.cmd) as command,
|
|
1219
|
+
p.qual as using_expr, p.with_check as check_expr,
|
|
1220
|
+
array(
|
|
1221
|
+
select distinct fn.nspname || '.' || pr.proname
|
|
1222
|
+
from pg_policy pol
|
|
1223
|
+
join pg_depend d on d.classid = 'pg_policy'::regclass and d.objid = pol.oid
|
|
1224
|
+
and d.refclassid = 'pg_proc'::regclass
|
|
1225
|
+
join pg_proc pr on pr.oid = d.refobjid
|
|
1226
|
+
join pg_namespace fn on fn.oid = pr.pronamespace
|
|
1227
|
+
where pol.polrelid = c.oid and pol.polname = p.policyname
|
|
1228
|
+
order by 1
|
|
1229
|
+
)::text[] as functions
|
|
1230
|
+
from pg_policies p
|
|
1231
|
+
join pg_namespace n on n.nspname = p.schemaname
|
|
1232
|
+
join pg_class c on c.relnamespace = n.oid and c.relname = p.tablename
|
|
1233
|
+
where p.schemaname = any(${schemas})
|
|
1234
|
+
order by 1, 2`;
|
|
1235
|
+
const FUNCTIONS = (schemas) => `
|
|
1236
|
+
select n.nspname as schema, p.proname as name,
|
|
1237
|
+
pg_get_function_identity_arguments(p.oid) as signature,
|
|
1238
|
+
l.lanname as language, p.provolatile as volatility,
|
|
1239
|
+
p.prosecdef as security_definer, coalesce(p.proconfig, '{}')::text[] as config,
|
|
1240
|
+
array(
|
|
1241
|
+
select r from unnest(array['anon', 'authenticated']) r
|
|
1242
|
+
where has_function_privilege(r, p.oid, 'EXECUTE')
|
|
1243
|
+
) as execute
|
|
1244
|
+
from pg_proc p
|
|
1245
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
1246
|
+
join pg_language l on l.oid = p.prolang
|
|
1247
|
+
where p.oid in (
|
|
1248
|
+
select d.refobjid
|
|
1249
|
+
from pg_depend d
|
|
1250
|
+
join pg_policy pol on d.classid = 'pg_policy'::regclass and d.objid = pol.oid
|
|
1251
|
+
join pg_class c on c.oid = pol.polrelid
|
|
1252
|
+
join pg_namespace cn on cn.oid = c.relnamespace
|
|
1253
|
+
where d.refclassid = 'pg_proc'::regclass and cn.nspname = any(${schemas})
|
|
1254
|
+
)
|
|
1255
|
+
or (n.nspname = any(${schemas}) and ((p.prosecdef
|
|
1256
|
+
and p.prorettype not in ('trigger'::regtype, 'event_trigger'::regtype)) or exists (
|
|
1257
|
+
select 1 from unnest(p.proconfig) setting where setting not like 'search_path=%'
|
|
1258
|
+
)))
|
|
1259
|
+
order by 1, 2, 3`;
|
|
1260
|
+
/** Roles the hook checks care about: the one Auth calls it as, and the API roles. */
|
|
1261
|
+
const HOOK_ROLES = literalArray([
|
|
1262
|
+
"supabase_auth_admin",
|
|
1263
|
+
"authenticated",
|
|
1264
|
+
"anon"
|
|
1265
|
+
]);
|
|
1266
|
+
const HOOKS = (names) => `
|
|
1267
|
+
select n.nspname as schema, p.proname as name,
|
|
1268
|
+
pg_get_function_identity_arguments(p.oid) as signature,
|
|
1269
|
+
l.lanname as language, p.provolatile as volatility,
|
|
1270
|
+
p.prosecdef as security_definer, coalesce(p.proconfig, '{}')::text[] as config,
|
|
1271
|
+
array(select r.rolname::text from pg_roles r
|
|
1272
|
+
where r.rolname = any(${HOOK_ROLES}) and has_function_privilege(r.oid, p.oid, 'execute')
|
|
1273
|
+
order by 1) as execute,
|
|
1274
|
+
exists(select 1 from aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) a
|
|
1275
|
+
where a.grantee = 0 and a.privilege_type = 'EXECUTE') as public_execute,
|
|
1276
|
+
array(select r.rolname::text from pg_roles r
|
|
1277
|
+
where r.rolname = any(${HOOK_ROLES}) and has_schema_privilege(r.oid, n.oid, 'usage')
|
|
1278
|
+
order by 1) as schema_usage,
|
|
1279
|
+
p.prosrc as source
|
|
1280
|
+
from pg_proc p
|
|
1281
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
1282
|
+
join pg_language l on l.oid = p.prolang
|
|
1283
|
+
where n.nspname || '.' || p.proname = any(${names})
|
|
1284
|
+
order by 1, 2, 3`;
|
|
1285
|
+
const TRIGGERS = (schemas) => `
|
|
1286
|
+
select t.tgrelid::int8 as table_id, t.tgname as name,
|
|
1287
|
+
pn.nspname || '.' || p.proname as function,
|
|
1288
|
+
case when t.tgtype & 2 = 2 then 'before' when t.tgtype & 64 = 64 then 'instead of' else 'after' end as timing,
|
|
1289
|
+
array_remove(array[
|
|
1290
|
+
case when t.tgtype & 4 = 4 then 'insert' end,
|
|
1291
|
+
case when t.tgtype & 16 = 16 then 'update' end,
|
|
1292
|
+
case when t.tgtype & 8 = 8 then 'delete' end,
|
|
1293
|
+
case when t.tgtype & 32 = 32 then 'truncate' end
|
|
1294
|
+
], null)::text[] as events,
|
|
1295
|
+
case when t.tgtype & 1 = 1 then 'row' else 'statement' end as level
|
|
1296
|
+
from pg_trigger t
|
|
1297
|
+
join pg_class c on c.oid = t.tgrelid
|
|
1298
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
1299
|
+
join pg_proc p on p.oid = t.tgfoid
|
|
1300
|
+
join pg_namespace pn on pn.oid = p.pronamespace
|
|
1301
|
+
where not t.tgisinternal and n.nspname = any(${schemas})
|
|
1302
|
+
order by 1, 2`;
|
|
1303
|
+
const GRANTS = (schemas) => `
|
|
1304
|
+
select c.oid::int8 as table_id, g.grantee as role,
|
|
1305
|
+
array_agg(g.privilege_type::text order by g.privilege_type)::text[] as privileges
|
|
1306
|
+
from information_schema.role_table_grants g
|
|
1307
|
+
join pg_namespace n on n.nspname = g.table_schema
|
|
1308
|
+
join pg_class c on c.relnamespace = n.oid and c.relname = g.table_name
|
|
1309
|
+
where g.table_schema = any(${schemas}) and g.grantee in ('anon', 'authenticated', 'service_role')
|
|
1310
|
+
group by 1, 2
|
|
1311
|
+
order by 1, 2`;
|
|
1312
|
+
const COLUMN_GRANTS = (schemas) => `
|
|
1313
|
+
select c.oid::int8 as table_id, a.attname as column, r.rolname as role,
|
|
1314
|
+
array_agg(distinct acl.privilege_type order by acl.privilege_type)::text[] as privileges
|
|
1315
|
+
from pg_class c
|
|
1316
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
1317
|
+
join pg_attribute a on a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped and a.attacl is not null
|
|
1318
|
+
cross join lateral aclexplode(a.attacl) acl
|
|
1319
|
+
join pg_roles r on r.oid = acl.grantee
|
|
1320
|
+
where n.nspname = any(${schemas}) and r.rolname in ('anon', 'authenticated')
|
|
1321
|
+
and acl.privilege_type in ('INSERT', 'UPDATE')
|
|
1322
|
+
group by 1, 2, 3
|
|
1323
|
+
order by 1, 2, 3`;
|
|
1324
|
+
const BUCKETS = `
|
|
1325
|
+
select id, public, file_size_limit, allowed_mime_types
|
|
1326
|
+
from storage.buckets
|
|
1327
|
+
order by id`;
|
|
1328
|
+
const REALTIME = `
|
|
1329
|
+
select schemaname || '.' || tablename as name
|
|
1330
|
+
from pg_publication_tables
|
|
1331
|
+
where pubname = 'supabase_realtime'
|
|
1332
|
+
order by 1`;
|
|
1333
|
+
const ROLE_SETTINGS = `
|
|
1334
|
+
select r.rolname as role, s.setconfig as config
|
|
1335
|
+
from pg_catalog.pg_db_role_setting s
|
|
1336
|
+
join pg_catalog.pg_roles r on r.oid = s.setrole
|
|
1337
|
+
where r.rolname in ('authenticator', 'anon', 'authenticated')
|
|
1338
|
+
and s.setdatabase in (0, (select oid from pg_catalog.pg_database where datname = current_database()))
|
|
1339
|
+
order by s.setdatabase`;
|
|
1340
|
+
const HAS_TABLE = (qualified) => `select to_regclass('${qualified}') is not null as present`;
|
|
1341
|
+
const ACTIONS = {
|
|
1342
|
+
a: "no action",
|
|
1343
|
+
r: "restrict",
|
|
1344
|
+
c: "cascade",
|
|
1345
|
+
n: "set null",
|
|
1346
|
+
d: "set default"
|
|
1347
|
+
};
|
|
1348
|
+
const VOLATILITY = {
|
|
1349
|
+
i: "immutable",
|
|
1350
|
+
s: "stable",
|
|
1351
|
+
v: "volatile"
|
|
1352
|
+
};
|
|
1353
|
+
/** `['statement_timeout=5s']` as `{ statement_timeout: '5s' }`. */
|
|
1354
|
+
function settingsOf(config) {
|
|
1355
|
+
const settings = {};
|
|
1356
|
+
for (const entry of config ?? []) {
|
|
1357
|
+
const at = entry.indexOf("=");
|
|
1358
|
+
if (at > 0) settings[entry.slice(0, at)] = entry.slice(at + 1);
|
|
1359
|
+
}
|
|
1360
|
+
return settings;
|
|
1361
|
+
}
|
|
1362
|
+
const functionOf = (row) => ({
|
|
1363
|
+
schema: row.schema,
|
|
1364
|
+
name: row.name,
|
|
1365
|
+
signature: row.signature,
|
|
1366
|
+
language: row.language,
|
|
1367
|
+
volatility: VOLATILITY[row.volatility],
|
|
1368
|
+
securityDefiner: row.security_definer,
|
|
1369
|
+
settings: settingsOf(row.config)
|
|
1370
|
+
});
|
|
1371
|
+
/** The functions behind Auth hooks, in any schema, with their ACLs. */
|
|
1372
|
+
async function readHooks(db, targets) {
|
|
1373
|
+
if (targets.length === 0) return [];
|
|
1374
|
+
const found = await rows(db, HOOKS(literalArray(targets.map((t) => `${t.schema}.${t.name}`))));
|
|
1375
|
+
return targets.map((target) => ({
|
|
1376
|
+
hook: target.hook,
|
|
1377
|
+
schema: target.schema,
|
|
1378
|
+
name: target.name,
|
|
1379
|
+
functions: found.filter((row) => row.schema === target.schema && row.name === target.name).map((row) => ({
|
|
1380
|
+
...functionOf(row),
|
|
1381
|
+
execute: row.execute,
|
|
1382
|
+
publicExecute: row.public_execute,
|
|
1383
|
+
schemaUsage: row.schema_usage,
|
|
1384
|
+
...row.source === null ? {} : { source: row.source }
|
|
1385
|
+
}))
|
|
1386
|
+
}));
|
|
1387
|
+
}
|
|
1388
|
+
async function rows(db, sql) {
|
|
1389
|
+
return (await db.query(sql)).rows;
|
|
1390
|
+
}
|
|
1391
|
+
/** Rows from a table owned by another Supabase service, if it exists. */
|
|
1392
|
+
async function serviceRows(db, qualified, sql) {
|
|
1393
|
+
const [probe] = await rows(db, HAS_TABLE(qualified));
|
|
1394
|
+
return probe?.present ? rows(db, sql) : [];
|
|
1395
|
+
}
|
|
1396
|
+
function groupById(list) {
|
|
1397
|
+
const map = /* @__PURE__ */ new Map();
|
|
1398
|
+
for (const row of list) {
|
|
1399
|
+
const id = Number(row.table_id);
|
|
1400
|
+
const entry = map.get(id);
|
|
1401
|
+
if (entry) entry.push(row);
|
|
1402
|
+
else map.set(id, [row]);
|
|
1403
|
+
}
|
|
1404
|
+
return map;
|
|
1405
|
+
}
|
|
1406
|
+
/** Reads the extras for `schemas`, with the catalog queries in flight together. */
|
|
1407
|
+
async function readExtras(db, schemas, hooks = []) {
|
|
1408
|
+
const list = literalArray(schemas);
|
|
1409
|
+
const [relations, constraintRows, indexRows, policyRows, triggerRows, grantRows, columnGrantRows, buckets, realtime, functions, roleRows, hookRows] = await Promise.all([
|
|
1410
|
+
rows(db, RELATIONS(list)),
|
|
1411
|
+
rows(db, CONSTRAINTS(list)),
|
|
1412
|
+
rows(db, INDEXES(list)),
|
|
1413
|
+
rows(db, POLICIES(list)),
|
|
1414
|
+
rows(db, TRIGGERS(list)),
|
|
1415
|
+
rows(db, GRANTS(list)),
|
|
1416
|
+
rows(db, COLUMN_GRANTS(list)),
|
|
1417
|
+
serviceRows(db, "storage.buckets", BUCKETS),
|
|
1418
|
+
rows(db, REALTIME),
|
|
1419
|
+
rows(db, FUNCTIONS(list)),
|
|
1420
|
+
rows(db, ROLE_SETTINGS),
|
|
1421
|
+
hooks.length > 0 ? readHooks(db, hooks) : void 0
|
|
1422
|
+
]);
|
|
1423
|
+
const constraints = groupById(constraintRows);
|
|
1424
|
+
const indexes = groupById(indexRows);
|
|
1425
|
+
const policies = groupById(policyRows);
|
|
1426
|
+
const triggers = groupById(triggerRows);
|
|
1427
|
+
const grants = groupById(grantRows);
|
|
1428
|
+
const columnGrants = groupById(columnGrantRows);
|
|
1429
|
+
const roleSettings = {};
|
|
1430
|
+
for (const row of roleRows) Object.assign(roleSettings[row.role] ??= {}, settingsOf(row.config));
|
|
1431
|
+
return {
|
|
1432
|
+
tables: relations.map((relation) => {
|
|
1433
|
+
const id = Number(relation.id);
|
|
1434
|
+
const own = constraints.get(id) ?? [];
|
|
1435
|
+
const ownIndexes = indexes.get(id) ?? [];
|
|
1436
|
+
const uniques = own.filter((con) => con.type === "u").map((con) => ({
|
|
1437
|
+
name: con.name,
|
|
1438
|
+
columns: con.columns
|
|
1439
|
+
}));
|
|
1440
|
+
const constraintNames = new Set(own.map((con) => con.name));
|
|
1441
|
+
for (const index of ownIndexes) if (index.unique && !index.primary && !index.partial && !constraintNames.has(index.name)) uniques.push({
|
|
1442
|
+
name: index.name,
|
|
1443
|
+
columns: index.columns
|
|
1444
|
+
});
|
|
1445
|
+
return {
|
|
1446
|
+
id,
|
|
1447
|
+
schema: relation.schema,
|
|
1448
|
+
name: relation.name,
|
|
1449
|
+
primaryKey: own.find((con) => con.type === "p")?.columns ?? [],
|
|
1450
|
+
uniques,
|
|
1451
|
+
foreignKeys: own.filter((con) => con.type === "f").map((con) => ({
|
|
1452
|
+
name: con.name,
|
|
1453
|
+
onDelete: ACTIONS[con.on_delete] ?? "no action",
|
|
1454
|
+
onUpdate: ACTIONS[con.on_update] ?? "no action"
|
|
1455
|
+
})),
|
|
1456
|
+
checks: own.filter((con) => con.type === "c").map((con) => ({
|
|
1457
|
+
name: con.name,
|
|
1458
|
+
definition: con.definition
|
|
1459
|
+
})),
|
|
1460
|
+
indexes: ownIndexes.map((index) => ({
|
|
1461
|
+
name: index.name,
|
|
1462
|
+
columns: index.columns,
|
|
1463
|
+
unique: index.unique,
|
|
1464
|
+
primary: index.primary,
|
|
1465
|
+
partial: index.partial,
|
|
1466
|
+
method: index.method,
|
|
1467
|
+
predicate: index.predicate
|
|
1468
|
+
})),
|
|
1469
|
+
policies: (policies.get(id) ?? []).map((policy) => ({
|
|
1470
|
+
name: policy.name,
|
|
1471
|
+
command: policy.command,
|
|
1472
|
+
roles: policy.roles,
|
|
1473
|
+
permissive: policy.permissive,
|
|
1474
|
+
using: policy.using_expr,
|
|
1475
|
+
check: policy.check_expr,
|
|
1476
|
+
functions: policy.functions ?? []
|
|
1477
|
+
})),
|
|
1478
|
+
triggers: (triggers.get(id) ?? []).map((trigger) => ({
|
|
1479
|
+
name: trigger.name,
|
|
1480
|
+
timing: trigger.timing,
|
|
1481
|
+
events: trigger.events,
|
|
1482
|
+
level: trigger.level,
|
|
1483
|
+
function: trigger.function
|
|
1484
|
+
})),
|
|
1485
|
+
grants: (grants.get(id) ?? []).map((grant) => ({
|
|
1486
|
+
role: grant.role,
|
|
1487
|
+
privileges: grant.privileges
|
|
1488
|
+
})),
|
|
1489
|
+
columnGrants: (columnGrants.get(id) ?? []).map((grant) => ({
|
|
1490
|
+
column: grant.column,
|
|
1491
|
+
role: grant.role,
|
|
1492
|
+
privileges: grant.privileges
|
|
1493
|
+
})),
|
|
1494
|
+
...relation.large ? { large: true } : {}
|
|
1495
|
+
};
|
|
1496
|
+
}),
|
|
1497
|
+
buckets: buckets.map((row) => ({
|
|
1498
|
+
id: row.id,
|
|
1499
|
+
public: row.public,
|
|
1500
|
+
fileSizeLimit: row.file_size_limit === null ? null : Number(row.file_size_limit),
|
|
1501
|
+
allowedMimeTypes: row.allowed_mime_types
|
|
1502
|
+
})),
|
|
1503
|
+
realtime: realtime.map((row) => row.name),
|
|
1504
|
+
roleSettings,
|
|
1505
|
+
functions: functions.map((row) => ({
|
|
1506
|
+
...functionOf(row),
|
|
1507
|
+
execute: row.execute
|
|
1508
|
+
})),
|
|
1509
|
+
...hookRows ? { hooks: hookRows } : {}
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
//#endregion
|
|
1513
|
+
//#region src/cli/introspect/fingerprint.ts
|
|
1514
|
+
/** Catalogs whose rows only change with DDL, grants, comments or settings. */
|
|
1515
|
+
const CATALOGS = [
|
|
1516
|
+
"pg_namespace",
|
|
1517
|
+
"pg_attribute",
|
|
1518
|
+
"pg_attrdef",
|
|
1519
|
+
"pg_constraint",
|
|
1520
|
+
"pg_index",
|
|
1521
|
+
"pg_inherits",
|
|
1522
|
+
"pg_rewrite",
|
|
1523
|
+
"pg_policy",
|
|
1524
|
+
"pg_trigger",
|
|
1525
|
+
"pg_proc",
|
|
1526
|
+
"pg_type",
|
|
1527
|
+
"pg_enum",
|
|
1528
|
+
"pg_description",
|
|
1529
|
+
"pg_db_role_setting",
|
|
1530
|
+
"pg_publication",
|
|
1531
|
+
"pg_publication_rel",
|
|
1532
|
+
"pg_extension"
|
|
1533
|
+
];
|
|
1534
|
+
const digest = (from) => `(select md5(coalesce(string_agg(x::text, ',' order by x::text), '')) from ${from} x)`;
|
|
1535
|
+
/**
|
|
1536
|
+
* One cheap query whose result changes whenever introspection could. pg_class
|
|
1537
|
+
* leaves out the planner statistics that VACUUM and ANALYZE rewrite, except
|
|
1538
|
+
* the row-count threshold the snapshot records. Bucket rows are data, so they
|
|
1539
|
+
* are hashed when `storage.buckets` exists.
|
|
1540
|
+
*/
|
|
1541
|
+
const FINGERPRINT_SQL = `select md5(concat_ws('|',
|
|
1542
|
+
${digest(`(select oid, relname, relnamespace, relkind, relacl, reloptions, relrowsecurity, relforcerowsecurity, relreplident, relispartition, reltuples >= ${LARGE_TABLE_ROWS} as large from pg_catalog.pg_class)`)},
|
|
1543
|
+
${CATALOGS.map((name) => digest(`pg_catalog.${name}`)).join(",\n ")},
|
|
1544
|
+
(select md5(coalesce(string_agg(rolname, ',' order by rolname), '')) from pg_catalog.pg_roles),
|
|
1545
|
+
case when to_regclass('storage.buckets') is not null
|
|
1546
|
+
then md5(query_to_xml('select id, public, file_size_limit, allowed_mime_types from storage.buckets order by id', false, false, '')::text)
|
|
1547
|
+
end
|
|
1548
|
+
)) as fingerprint`;
|
|
1549
|
+
/** A hash of the catalog; equal values mean introspection would return the same snapshot. */
|
|
1550
|
+
async function catalogFingerprint(db) {
|
|
1551
|
+
const [row] = (await db.query(FINGERPRINT_SQL)).rows;
|
|
1552
|
+
const value = row && typeof row === "object" && "fingerprint" in row ? row.fingerprint : void 0;
|
|
1553
|
+
if (typeof value !== "string") throw new TypeError("The catalog fingerprint query returned no row.");
|
|
1554
|
+
return value;
|
|
1555
|
+
}
|
|
1556
|
+
//#endregion
|
|
1557
|
+
//#region src/cli/introspect/cache.ts
|
|
1558
|
+
/** Shared with the splinter download cache. */
|
|
1559
|
+
const CACHE_DIR = "node_modules/.cache/better-supabase";
|
|
1560
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
1561
|
+
function isEntry(value) {
|
|
1562
|
+
return typeof value === "object" && value !== null && "key" in value && typeof value.key === "string" && "snapshot" in value && typeof value.snapshot === "object";
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Calls `read`, or returns the snapshot cached under `root` when the catalog
|
|
1566
|
+
* fingerprint, the `inputs` (schemas and hooks) and the CLI version all
|
|
1567
|
+
* match. One file per source keeps the cache from growing. A source that
|
|
1568
|
+
* cannot report a fingerprint is read every time.
|
|
1569
|
+
*/
|
|
1570
|
+
async function cachedIntrospect(root, source, inputs, read) {
|
|
1571
|
+
const fingerprint = await catalogFingerprint(source.queryable).catch(() => void 0);
|
|
1572
|
+
if (fingerprint === void 0) return read();
|
|
1573
|
+
const key = sha256(JSON.stringify([
|
|
1574
|
+
VERSION,
|
|
1575
|
+
source.describe,
|
|
1576
|
+
inputs,
|
|
1577
|
+
fingerprint
|
|
1578
|
+
]));
|
|
1579
|
+
const file = resolve(root, CACHE_DIR, `snapshot-${sha256(source.describe).slice(0, 16)}.json`);
|
|
1580
|
+
const cached = await readFile(file, "utf8").then((text) => JSON.parse(text)).catch(() => void 0);
|
|
1581
|
+
if (isEntry(cached) && cached.key === key) return cached.snapshot;
|
|
1582
|
+
const snapshot = await read();
|
|
1583
|
+
const entry = {
|
|
1584
|
+
key,
|
|
1585
|
+
snapshot
|
|
1586
|
+
};
|
|
1587
|
+
await mkdir(dirname(file), { recursive: true }).then(() => writeFile(file, JSON.stringify(entry))).catch(() => void 0);
|
|
1588
|
+
return snapshot;
|
|
1589
|
+
}
|
|
1590
|
+
//#endregion
|
|
1591
|
+
//#region src/cli/introspect/source.ts
|
|
1592
|
+
const POOL_SIZE = 4;
|
|
1593
|
+
const STATEMENT_TIMEOUT_MS = 12e4;
|
|
1594
|
+
/**
|
|
1595
|
+
* A small `pg` pool, since introspection issues its queries concurrently. A
|
|
1596
|
+
* `connect` that returns a single client gets its queries queued, because one
|
|
1597
|
+
* `pg.Client` runs them one at a time.
|
|
1598
|
+
*/
|
|
1599
|
+
async function pgSource(url, open = connect, signal) {
|
|
1600
|
+
const { client, pooled, close, describe } = await open(url, void 0, {
|
|
1601
|
+
pool: POOL_SIZE,
|
|
1602
|
+
statementTimeout: STATEMENT_TIMEOUT_MS,
|
|
1603
|
+
...signal ? { signal } : {}
|
|
1604
|
+
});
|
|
1605
|
+
if (pooled) return {
|
|
1606
|
+
queryable: client,
|
|
1607
|
+
close,
|
|
1608
|
+
describe
|
|
1609
|
+
};
|
|
1610
|
+
let queue = Promise.resolve();
|
|
1611
|
+
return {
|
|
1612
|
+
queryable: { query(sql) {
|
|
1613
|
+
const next = queue.then(() => client.query(sql));
|
|
1614
|
+
queue = next.catch(() => void 0);
|
|
1615
|
+
return next;
|
|
1616
|
+
} },
|
|
1617
|
+
close,
|
|
1618
|
+
describe
|
|
1619
|
+
};
|
|
1620
|
+
}
|
|
1621
|
+
/**
|
|
1622
|
+
* Runs introspection SQL through the Supabase Management API as the
|
|
1623
|
+
* read-only database user, so hosted projects need no database password.
|
|
1624
|
+
*/
|
|
1625
|
+
function managementSource(options) {
|
|
1626
|
+
const base = (options.apiUrl ?? "https://api.supabase.com").replace(/\/$/, "");
|
|
1627
|
+
const doFetch = options.fetch ?? fetch;
|
|
1628
|
+
const endpoint = `${base}/v1/projects/${encodeURIComponent(options.projectRef)}/database/query/read-only`;
|
|
1629
|
+
return {
|
|
1630
|
+
queryable: { async query(sql) {
|
|
1631
|
+
const response = await doFetch(endpoint, {
|
|
1632
|
+
method: "POST",
|
|
1633
|
+
headers: {
|
|
1634
|
+
authorization: `Bearer ${options.accessToken}`,
|
|
1635
|
+
"content-type": "application/json"
|
|
1636
|
+
},
|
|
1637
|
+
body: JSON.stringify({ query: sql }),
|
|
1638
|
+
...options.signal ? { signal: options.signal } : {}
|
|
1639
|
+
});
|
|
1640
|
+
const text = await response.text();
|
|
1641
|
+
if (!response.ok) throw new Error(`Management API query failed (${response.status}) for project ${options.projectRef}: ${text.slice(0, 500)}`);
|
|
1642
|
+
const rows = JSON.parse(text);
|
|
1643
|
+
if (!Array.isArray(rows)) throw new TypeError(`Management API returned an unexpected body for project ${options.projectRef}.`);
|
|
1644
|
+
return { rows };
|
|
1645
|
+
} },
|
|
1646
|
+
describe: `project ${options.projectRef} (Management API)`,
|
|
1647
|
+
close: () => Promise.resolve()
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
//#endregion
|
|
1651
|
+
//#region src/cli/introspect/index.ts
|
|
1652
|
+
/** Reads a v2 snapshot: typegen metadata plus the extras. */
|
|
1653
|
+
async function introspect(db, schemas, options = {}) {
|
|
1654
|
+
const unique = [...new Set(schemas)].sort();
|
|
1655
|
+
const [metadata, extras] = await Promise.all([readGeneratorMetadata(db, unique), readExtras(db, unique, options.hooks)]);
|
|
1656
|
+
const { metadata: generator, ids } = stabilizeMetadata(metadata);
|
|
1657
|
+
return {
|
|
1658
|
+
version: 2,
|
|
1659
|
+
schemas: unique,
|
|
1660
|
+
generator,
|
|
1661
|
+
extras: {
|
|
1662
|
+
...extras,
|
|
1663
|
+
tables: extras.tables.map((table) => ({
|
|
1664
|
+
...table,
|
|
1665
|
+
id: ids.get(table.id) ?? table.id
|
|
1666
|
+
}))
|
|
1667
|
+
}
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
//#endregion
|
|
1671
|
+
//#region src/cli/commands/snapshot.ts
|
|
1672
|
+
const SNAPSHOT_SCHEMA_URL = "https://unpkg.com/better-supabase/schemas/snapshot-v2.json";
|
|
1673
|
+
/** Schemas that are always read, for doctor checks on helper functions. */
|
|
1674
|
+
const EXTRA_SCHEMAS = ["better_supabase"];
|
|
1675
|
+
/** The hosted project to read through the Management API, if any. */
|
|
1676
|
+
function managementTarget(config, env, source) {
|
|
1677
|
+
if (source.dbUrl) return void 0;
|
|
1678
|
+
const projectRef = source.projectRef ?? (config.source.dbUrl ? void 0 : config.source.projectRef);
|
|
1679
|
+
if (!projectRef) return void 0;
|
|
1680
|
+
const accessToken = config.source.accessToken ?? env.SUPABASE_ACCESS_TOKEN;
|
|
1681
|
+
if (!accessToken) throw new CliError("missing_value", `Reading project ${projectRef} needs a Supabase access token. Set SUPABASE_ACCESS_TOKEN to a token scoped to this project (https://supabase.com/dashboard/account/tokens), or read the database with $DATABASE_URL or --db-url-stdin.`, { flag: "SUPABASE_ACCESS_TOKEN" });
|
|
1682
|
+
return {
|
|
1683
|
+
projectRef,
|
|
1684
|
+
accessToken,
|
|
1685
|
+
...env.SUPABASE_API_URL ? { apiUrl: env.SUPABASE_API_URL } : {}
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
/**
|
|
1689
|
+
* Where to read the schema from: `--db-url-stdin`, `--project-ref`, then the
|
|
1690
|
+
* config's `source.dbUrl` / `source.projectRef`, `$DATABASE_URL`, and finally
|
|
1691
|
+
* the local stack.
|
|
1692
|
+
*/
|
|
1693
|
+
async function openSource(config, env, source, open = connect) {
|
|
1694
|
+
if (source.dbUrl) return pgSource(source.dbUrl, open, source.signal);
|
|
1695
|
+
const target = managementTarget(config, env, source);
|
|
1696
|
+
if (target) return managementSource({
|
|
1697
|
+
...target,
|
|
1698
|
+
...source.signal ? { signal: source.signal } : {}
|
|
1699
|
+
});
|
|
1700
|
+
return pgSource(await databaseUrl(config, env), open, source.signal);
|
|
1701
|
+
}
|
|
1702
|
+
/** Whether `loadSnapshot` reads a saved file rather than a database. */
|
|
1703
|
+
function snapshotFile(config, source) {
|
|
1704
|
+
return source.snapshotPath ?? (source.live || source.dbUrl || source.projectRef ? void 0 : config.source.snapshot);
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* Loads a saved snapshot or introspects the database. With `shared`, it
|
|
1708
|
+
* introspects over that source and leaves closing it to the caller, so later
|
|
1709
|
+
* queries reuse the connection.
|
|
1710
|
+
*/
|
|
1711
|
+
async function loadSnapshot(config, env, source, open = connect, shared) {
|
|
1712
|
+
const path = snapshotFile(config, source);
|
|
1713
|
+
if (path) return readSnapshotFile(resolve(config.root, path), path);
|
|
1714
|
+
const [toml, db] = await Promise.all([readSupabaseToml(config.root), shared ? shared() : openSource(config, env, source, open)]);
|
|
1715
|
+
const hooks = toml ? pgFunctionHooks(toml.document) : [];
|
|
1716
|
+
const schemas = [...config.schemas, ...EXTRA_SCHEMAS];
|
|
1717
|
+
try {
|
|
1718
|
+
const read = () => introspect(db.queryable, schemas, { hooks });
|
|
1719
|
+
return await (source.cache ? cachedIntrospect(config.root, db, [schemas, hooks], read) : read());
|
|
1720
|
+
} finally {
|
|
1721
|
+
if (!shared) await db.close();
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
async function readSnapshotFile(absolute, label = absolute) {
|
|
1725
|
+
if (!existsSync(absolute)) throw new Error(`Snapshot not found: ${label}`);
|
|
1726
|
+
return parseSnapshot(JSON.parse(await readFile(absolute, "utf8")), label);
|
|
1727
|
+
}
|
|
1728
|
+
/** Validates a parsed snapshot document. */
|
|
1729
|
+
async function parseSnapshot(data, label = "snapshot") {
|
|
1730
|
+
const doc = data;
|
|
1731
|
+
if (!doc || typeof doc !== "object" || doc.version !== 2) throw new Error(`${label} is not a version 2 snapshot. Run \`better-supabase introspect\` to refresh it.`);
|
|
1732
|
+
if (!Array.isArray(doc.schemas) || !doc.extras || !Array.isArray(doc.extras.tables)) throw new Error(`${label} is missing "schemas" or "extras".`);
|
|
1733
|
+
let generator;
|
|
1734
|
+
try {
|
|
1735
|
+
generator = await validateGeneratorMetadata(doc.generator);
|
|
1736
|
+
} catch (cause) {
|
|
1737
|
+
throw new Error(`${label} has invalid "generator" metadata: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
1738
|
+
}
|
|
1739
|
+
return {
|
|
1740
|
+
version: 2,
|
|
1741
|
+
schemas: doc.schemas,
|
|
1742
|
+
generator,
|
|
1743
|
+
extras: {
|
|
1744
|
+
tables: doc.extras.tables,
|
|
1745
|
+
buckets: doc.extras.buckets ?? [],
|
|
1746
|
+
realtime: doc.extras.realtime ?? [],
|
|
1747
|
+
...doc.extras.roleSettings ? { roleSettings: doc.extras.roleSettings } : {},
|
|
1748
|
+
...doc.extras.functions ? { functions: doc.extras.functions } : {},
|
|
1749
|
+
...doc.extras.hooks ? { hooks: doc.extras.hooks } : {}
|
|
1750
|
+
}
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
function serializeSnapshot(snapshot) {
|
|
1754
|
+
const { $schema: _ignored, ...rest } = snapshot;
|
|
1755
|
+
return `${JSON.stringify({
|
|
1756
|
+
$schema: SNAPSHOT_SCHEMA_URL,
|
|
1757
|
+
...rest
|
|
1758
|
+
}, null, 2)}\n`;
|
|
1759
|
+
}
|
|
1760
|
+
//#endregion
|
|
1761
|
+
//#region src/cli/commands/gen.ts
|
|
1762
|
+
/**
|
|
1763
|
+
* Renders every generated file for a snapshot, without touching disk:
|
|
1764
|
+
* `database.types.ts` (what `supabase gen types` prints), the main module,
|
|
1765
|
+
* then each configured generator's files.
|
|
1766
|
+
*/
|
|
1767
|
+
async function renderFiles(config, snapshot) {
|
|
1768
|
+
const model = buildModel(snapshot, config);
|
|
1769
|
+
const output = resolve(config.root, config.output);
|
|
1770
|
+
const databaseTypes = await generateDatabaseTypes(snapshot.generator, {
|
|
1771
|
+
schemas: config.schemas,
|
|
1772
|
+
postgrestVersion: config.postgrestVersion
|
|
1773
|
+
});
|
|
1774
|
+
const metaFiles = metaPaths(config.output);
|
|
1775
|
+
const main = emitModule(model, {
|
|
1776
|
+
databaseTypesImport: importPath(output, resolve(config.root, config.databaseTypesOutput)),
|
|
1777
|
+
importPathFor: (from) => importPath(output, resolve(config.root, from)),
|
|
1778
|
+
metaImport: `./${basename(metaFiles.js)}`
|
|
1779
|
+
});
|
|
1780
|
+
const meta = emitMeta(model, { types: `./${basename(metaFiles.dts)}` });
|
|
1781
|
+
const files = [
|
|
1782
|
+
{
|
|
1783
|
+
path: config.databaseTypesOutput,
|
|
1784
|
+
contents: databaseTypes
|
|
1785
|
+
},
|
|
1786
|
+
{
|
|
1787
|
+
path: config.output,
|
|
1788
|
+
contents: main
|
|
1789
|
+
},
|
|
1790
|
+
{
|
|
1791
|
+
path: metaFiles.js,
|
|
1792
|
+
contents: meta.js
|
|
1793
|
+
},
|
|
1794
|
+
{
|
|
1795
|
+
path: metaFiles.dts,
|
|
1796
|
+
contents: meta.dts
|
|
1797
|
+
}
|
|
1798
|
+
];
|
|
1799
|
+
for (const generator of config.generators) {
|
|
1800
|
+
const extra = await generator.generate({
|
|
1801
|
+
meta: model.meta,
|
|
1802
|
+
introspection: model.introspection,
|
|
1803
|
+
extras: snapshot.extras,
|
|
1804
|
+
config,
|
|
1805
|
+
output,
|
|
1806
|
+
importPath: (from, to) => importPath(resolve(config.root, from), resolve(config.root, to))
|
|
1807
|
+
});
|
|
1808
|
+
files.push(...extra);
|
|
1809
|
+
}
|
|
1810
|
+
return files;
|
|
1811
|
+
}
|
|
1812
|
+
/** The `read-sets` SQL kit file for `config.readSets`, if any are configured. */
|
|
1813
|
+
async function readSetFile(config) {
|
|
1814
|
+
if (config.readSets.length === 0) return void 0;
|
|
1815
|
+
const readSets = await compiledReadSets(config);
|
|
1816
|
+
return renderKit(["read-sets"], kitLayout(config, config.sql.testsDir, readSets))[0];
|
|
1817
|
+
}
|
|
1818
|
+
/** `buckets` keys that PermDock's catalog marks with row conditions, as error lines. */
|
|
1819
|
+
async function rowConditionedBuckets(config) {
|
|
1820
|
+
const configured = configuredPermdockKeys({ config });
|
|
1821
|
+
if (configured.length === 0) return [];
|
|
1822
|
+
const project = await readPermdock(config.root, config.permdock);
|
|
1823
|
+
const rowConditions = project?.rowConditions;
|
|
1824
|
+
if (!rowConditions) return [];
|
|
1825
|
+
return configured.flatMap(({ bucket, keys }) => keys.filter((key) => rowConditions.has(key)).map((key) => ` buckets.${bucket}: "${key}" has row conditions in ${project.catalogPath}`));
|
|
1826
|
+
}
|
|
1827
|
+
const UNFORMATTED = "oxfmt is not installed, so database.types.ts is not formatted like `supabase gen types` output. Install it: pnpm add -D oxfmt";
|
|
1828
|
+
async function runGen(options) {
|
|
1829
|
+
const { config } = options;
|
|
1830
|
+
const refused = await rowConditionedBuckets(config);
|
|
1831
|
+
if (refused.length > 0) return {
|
|
1832
|
+
code: 1,
|
|
1833
|
+
error: `PermDock's SQL helpers don't check row conditions, so these bucket policies would grant every object in the scope:\n${refused.join("\n")}\nUse the policies \`permdock rls generate\` writes for them (doctor BS214).`
|
|
1834
|
+
};
|
|
1835
|
+
const snapshot = options.snapshot ?? await loadSnapshot(config, options.env, options);
|
|
1836
|
+
const files = await renderFiles(config, snapshot);
|
|
1837
|
+
const notice = await oxfmtInstalled() ? "" : `\n${UNFORMATTED}`;
|
|
1838
|
+
if (options.check) {
|
|
1839
|
+
const stale = [];
|
|
1840
|
+
const diffs = [];
|
|
1841
|
+
const compare = (file, current) => {
|
|
1842
|
+
const shown = display(config.root, file.path);
|
|
1843
|
+
stale.push(shown);
|
|
1844
|
+
diffs.push(fileDiff(shown, current, file.contents, options.paint));
|
|
1845
|
+
};
|
|
1846
|
+
for (const file of files) {
|
|
1847
|
+
const path = resolve(config.root, file.path);
|
|
1848
|
+
const current = existsSync(path) ? await readFile(path, "utf8") : void 0;
|
|
1849
|
+
if (current !== file.contents) compare(file, current);
|
|
1850
|
+
}
|
|
1851
|
+
const readSets = await readSetFile(config);
|
|
1852
|
+
if (readSets) {
|
|
1853
|
+
const path = resolve(config.root, readSets.path);
|
|
1854
|
+
const current = existsSync(path) ? await readFile(path, "utf8") : void 0;
|
|
1855
|
+
if (!sameKitFile(current, readSets.contents)) compare(readSets, current);
|
|
1856
|
+
}
|
|
1857
|
+
if (stale.length > 0) return {
|
|
1858
|
+
code: 1,
|
|
1859
|
+
output: diffs.join("\n\n"),
|
|
1860
|
+
error: `Generated files are out of date:\n${stale.map((path) => ` ${path}`).join("\n")}\nRun \`better-supabase gen\`.`,
|
|
1861
|
+
data: {
|
|
1862
|
+
upToDate: false,
|
|
1863
|
+
stale
|
|
1864
|
+
}
|
|
1865
|
+
};
|
|
1866
|
+
return {
|
|
1867
|
+
code: 0,
|
|
1868
|
+
output: `Generated files are up to date (${files.length + (readSets ? 1 : 0)}).${notice}`,
|
|
1869
|
+
data: {
|
|
1870
|
+
upToDate: true,
|
|
1871
|
+
stale: []
|
|
1872
|
+
}
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
const written = [];
|
|
1876
|
+
for (const file of files) if (await writeIfChanged(resolve(config.root, file.path), file.contents)) written.push(display(config.root, file.path));
|
|
1877
|
+
const readSets = await readSetFile(config);
|
|
1878
|
+
if (readSets && await writeIfChanged(resolve(config.root, readSets.path), readSets.contents)) written.push(display(config.root, readSets.path));
|
|
1879
|
+
const tables = snapshot.extras.tables.filter((table) => config.schemas.includes(table.schema)).length;
|
|
1880
|
+
return {
|
|
1881
|
+
code: 0,
|
|
1882
|
+
output: written.length === 0 ? `No changes (${tables} tables).` : `Generated ${tables} tables:\n${written.map((path) => ` ${path}`).join("\n")}${notice}`,
|
|
1883
|
+
data: {
|
|
1884
|
+
tables,
|
|
1885
|
+
written
|
|
1886
|
+
}
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
/** Where to read the schema from, shared by gen, introspect and doctor. */
|
|
1890
|
+
const SOURCE_ARGS = {
|
|
1891
|
+
snapshot: {
|
|
1892
|
+
type: "string",
|
|
1893
|
+
description: "Read a saved snapshot instead of the database",
|
|
1894
|
+
valueHint: "file"
|
|
1895
|
+
},
|
|
1896
|
+
"db-url-stdin": {
|
|
1897
|
+
type: "boolean",
|
|
1898
|
+
description: "Read the connection string from stdin instead of $DATABASE_URL or the config"
|
|
1899
|
+
},
|
|
1900
|
+
"project-ref": {
|
|
1901
|
+
type: "string",
|
|
1902
|
+
description: "Read a hosted project through the Management API",
|
|
1903
|
+
valueHint: "ref"
|
|
1904
|
+
}
|
|
1905
|
+
};
|
|
1906
|
+
async function sourceArgs(args, io) {
|
|
1907
|
+
const snapshotPath = args.snapshot;
|
|
1908
|
+
const dbUrl = await stdinDatabaseUrl(args["db-url-stdin"], io);
|
|
1909
|
+
const projectRef = args["project-ref"];
|
|
1910
|
+
return {
|
|
1911
|
+
...snapshotPath ? { snapshotPath } : {},
|
|
1912
|
+
...dbUrl ? { dbUrl } : {},
|
|
1913
|
+
...projectRef ? { projectRef } : {}
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
const ARGS = {
|
|
1917
|
+
check: {
|
|
1918
|
+
type: "boolean",
|
|
1919
|
+
description: "Fail when a generated file is out of date"
|
|
1920
|
+
},
|
|
1921
|
+
watch: {
|
|
1922
|
+
type: "boolean",
|
|
1923
|
+
description: "Regenerate whenever the schema changes"
|
|
1924
|
+
},
|
|
1925
|
+
interval: {
|
|
1926
|
+
type: "string",
|
|
1927
|
+
description: "Milliseconds between checks in --watch. Defaults to 2000",
|
|
1928
|
+
valueHint: "ms"
|
|
1929
|
+
},
|
|
1930
|
+
...SOURCE_ARGS
|
|
1931
|
+
};
|
|
1932
|
+
const sleep = (ms, signal) => setTimeout(ms, void 0, signal ? { signal } : {}).catch(() => void 0);
|
|
1933
|
+
/**
|
|
1934
|
+
* Regenerates whenever the schema changes. A database source keeps one
|
|
1935
|
+
* connection open and polls the catalog fingerprint, so an unchanged schema
|
|
1936
|
+
* costs one small query per interval; a source without a fingerprint is
|
|
1937
|
+
* introspected each time and compared.
|
|
1938
|
+
*/
|
|
1939
|
+
/**
|
|
1940
|
+
* Reads the schema when it may have changed. Returns the new change marker (the
|
|
1941
|
+
* catalog fingerprint, or the snapshot itself when the source has none) and
|
|
1942
|
+
* the snapshot, or no snapshot when the fingerprint still matches `last`.
|
|
1943
|
+
*/
|
|
1944
|
+
async function poll(options, db, last) {
|
|
1945
|
+
const fingerprint = db ? await catalogFingerprint(db.queryable).catch(() => void 0) : void 0;
|
|
1946
|
+
if (fingerprint !== void 0 && fingerprint === last) return { marker: last };
|
|
1947
|
+
const snapshot = await loadSnapshot(options.config, options.env, options, void 0, db ? () => Promise.resolve(db) : void 0);
|
|
1948
|
+
const marker = fingerprint ?? JSON.stringify(snapshot);
|
|
1949
|
+
return marker === last ? { marker } : {
|
|
1950
|
+
marker,
|
|
1951
|
+
snapshot
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* Regenerates whenever the schema changes. A database source keeps one
|
|
1956
|
+
* connection open and polls the catalog fingerprint, so an unchanged schema
|
|
1957
|
+
* costs one small query per interval.
|
|
1958
|
+
*/
|
|
1959
|
+
async function watch(options, interval, { io, signal }) {
|
|
1960
|
+
let last = "";
|
|
1961
|
+
let db;
|
|
1962
|
+
const aborted = () => signal?.aborted ?? false;
|
|
1963
|
+
const fromFile = snapshotFile(options.config, options) !== void 0;
|
|
1964
|
+
try {
|
|
1965
|
+
while (!aborted()) {
|
|
1966
|
+
try {
|
|
1967
|
+
db ??= fromFile ? void 0 : await openSource(options.config, options.env, options);
|
|
1968
|
+
const { marker, snapshot } = await poll(options, db, last);
|
|
1969
|
+
last = marker;
|
|
1970
|
+
const result = snapshot ? await runGen({
|
|
1971
|
+
...options,
|
|
1972
|
+
snapshot
|
|
1973
|
+
}) : void 0;
|
|
1974
|
+
if (result?.output) io.stdout(`${result.output}\n`);
|
|
1975
|
+
if (result?.error) io.stderr(`${result.error}\n`);
|
|
1976
|
+
} catch (cause) {
|
|
1977
|
+
io.stderr(`${cause instanceof Error ? cause.message : String(cause)}\n`);
|
|
1978
|
+
await db?.close().catch(() => void 0);
|
|
1979
|
+
db = void 0;
|
|
1980
|
+
}
|
|
1981
|
+
await sleep(interval, signal);
|
|
1982
|
+
}
|
|
1983
|
+
} finally {
|
|
1984
|
+
await db?.close().catch(() => void 0);
|
|
1985
|
+
}
|
|
1986
|
+
return { code: 0 };
|
|
1987
|
+
}
|
|
1988
|
+
const genCommand = defineCliCommand({
|
|
1989
|
+
meta: {
|
|
1990
|
+
name: "gen",
|
|
1991
|
+
description: "Writes database.types.ts, generated.ts and its metadata module from the database"
|
|
1992
|
+
},
|
|
1993
|
+
args: ARGS,
|
|
1994
|
+
run: async (args, context) => {
|
|
1995
|
+
const options = {
|
|
1996
|
+
config: context.config,
|
|
1997
|
+
env: context.env,
|
|
1998
|
+
check: args.check === true,
|
|
1999
|
+
paint: painter(context.io.color),
|
|
2000
|
+
...await sourceArgs(args, context.io),
|
|
2001
|
+
...context.signal ? { signal: context.signal } : {},
|
|
2002
|
+
cache: true
|
|
2003
|
+
};
|
|
2004
|
+
return args.watch === true ? watch(options, Number(args.interval ?? 2e3), context) : withSpinner(context.io.prompts, "Generating from the schema", () => runGen(options));
|
|
2005
|
+
}
|
|
2006
|
+
});
|
|
2007
|
+
//#endregion
|
|
2008
|
+
export { catalogOf as C, qualified as D, policyObject as E, tableObject as O, entitlementsKit as S, lineOf as T, restrictSchemas as _, sourceArgs as a, emitModule as b, openSource as c, snapshotFile as d, introspect as f, buildModel as g, CACHE_DIR as h, runGen as i, toCatalog as k, parseSnapshot as l, pgSource as m, genCommand as n, loadSnapshot as o, managementSource as p, renderFiles as r, managementTarget as s, SOURCE_ARGS as t, serializeSnapshot as u, serializeGenerator as v, exposed as w, PERMDOCK_RULES as x, GENERATED_HEADER as y };
|