@exposify/supabase 0.3.13 → 0.3.15
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 +59 -17
- package/dist/cli.js +20 -9
- package/dist/index.cjs +24 -6
- package/dist/index.d.cts +18 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +24 -6
- package/package.json +2 -2
- package/sql/exposify_meta.sql +14 -2
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @exposify/supabase
|
|
1
|
+
# @exposify/supabase
|
|
2
2
|
|
|
3
3
|
Make a Supabase-backed app's database and edge functions usable by AI agents:
|
|
4
4
|
one edge function serves MCP, with tools generated from your database
|
|
@@ -7,9 +7,42 @@ register. The caller's JWT passes straight through to PostgREST — **RLS is
|
|
|
7
7
|
the authorization layer**. The agent can do exactly what that logged-in user
|
|
8
8
|
can do, nothing more.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
> **Which package?** Use **`@exposify/supabase`** when you want an MCP
|
|
11
|
+
> endpoint served straight from a Supabase **edge function** over PostgREST,
|
|
12
|
+
> with RLS as the authorization layer. If you have a **Next.js app** (even one
|
|
13
|
+
> whose users sign in with Supabase Auth), use
|
|
14
|
+
> [**`@exposify/next`**](https://github.com/ayadagain/agentready/tree/main/packages/next)
|
|
15
|
+
> instead, so tool calls run through your Next server and your own middleware
|
|
16
|
+
> and checks apply.
|
|
17
|
+
|
|
18
|
+
## Quickstart
|
|
19
|
+
|
|
20
|
+
From your Supabase project's root (the directory containing `supabase/`):
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npx @exposify/supabase init # scaffold the migration + mcp function
|
|
24
|
+
supabase db push # apply the exposify_meta() migration
|
|
25
|
+
supabase functions deploy mcp # deploy the MCP edge function
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then point an MCP client at the function's URL with a user's JWT — e.g. with
|
|
29
|
+
the Claude Code CLI:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
claude mcp add --transport http my-app \
|
|
33
|
+
https://<project-ref>.supabase.co/functions/v1/mcp \
|
|
34
|
+
--header "Authorization: Bearer <user-jwt>"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Every RLS-enabled table you can `SELECT` becomes a `query_<table>` tool
|
|
38
|
+
automatically; writes and RPCs are opt-in. `init` is interactive and can run
|
|
39
|
+
`db push`/`functions deploy` for you, wire in the hosted broker or Supabase
|
|
40
|
+
Auth OAuth (so clients sign in by themselves instead of pasting an hourly
|
|
41
|
+
JWT), and write tool descriptions from your SQL comments — see the sections
|
|
42
|
+
below. Check the whole setup any time with `npx @exposify/supabase doctor`.
|
|
43
|
+
|
|
44
|
+
The rest of this guide is reference, end to end. For the design rationale, see
|
|
45
|
+
the [design spec](https://github.com/ayadagain/agentready/blob/main/docs/superpowers/specs/2026-08-15-agentify-supabase-design.md).
|
|
13
46
|
|
|
14
47
|
## Prerequisites
|
|
15
48
|
|
|
@@ -28,15 +61,18 @@ npx @exposify/supabase init
|
|
|
28
61
|
|
|
29
62
|
This is safe to re-run; see "Re-running `init`" below. It writes:
|
|
30
63
|
|
|
31
|
-
- `supabase/migrations/<timestamp>
|
|
64
|
+
- `supabase/migrations/<timestamp>_exposify_meta_v3.sql` — a SQL migration that
|
|
32
65
|
adds `exposify_meta()`, a `SECURITY DEFINER` function returning, per table,
|
|
33
66
|
whether RLS is enabled, what the `authenticated` role is granted on it
|
|
34
|
-
(select/insert/update/delete),
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
67
|
+
(select/insert/update/delete), whether each column is an identity or
|
|
68
|
+
generated column (so `insert_<table>` doesn't ask for a `generated always as
|
|
69
|
+
identity` key the database rejects), and any `COMMENT ON TABLE`/`COMMENT ON
|
|
70
|
+
COLUMN` text you've written. It only ever returns catalog metadata
|
|
71
|
+
(table/column names, flags, comments) — never row data — which is what makes
|
|
72
|
+
it safe to grant to `anon`/`authenticated` and call with a low-privilege key.
|
|
73
|
+
A project on an older migration (`_exposify_meta.sql` with no grants, or
|
|
74
|
+
`_exposify_meta_v2.sql` with no identity/generated columns) gets the `_v3`
|
|
75
|
+
one on the next `init`, which replaces the function.
|
|
40
76
|
- `supabase/functions/mcp/index.ts` — the edge function itself. `init` owns
|
|
41
77
|
the part between `// exposify:begin` and `// exposify:end` (imports and a
|
|
42
78
|
`generated` options object). Everything after that is yours, starting with
|
|
@@ -196,7 +232,7 @@ things:
|
|
|
196
232
|
Until the project has a sign-in method (the Supabase preset in the
|
|
197
233
|
dashboard), agents use the function signed out, as `anon`, with no sign-in
|
|
198
234
|
asked: there's nothing to sign in with. The log says which it is
|
|
199
|
-
(`[
|
|
235
|
+
(`[exposify] How agents sign in: ...`), and adding the method takes effect
|
|
200
236
|
within a minute.
|
|
201
237
|
|
|
202
238
|
MCP clients then click Connect and sign in through the broker (or send a
|
|
@@ -252,6 +288,12 @@ Deno.serve(aiReady({
|
|
|
252
288
|
```
|
|
253
289
|
|
|
254
290
|
Notes:
|
|
291
|
+
- `insert_<table>` takes a `body` with one field per column, but only the
|
|
292
|
+
columns the caller actually supplies: a column with a `DEFAULT` or a `BY
|
|
293
|
+
DEFAULT` identity is optional (the database fills it in), and a `GENERATED
|
|
294
|
+
ALWAYS AS IDENTITY` column or a generated column is left out entirely (the
|
|
295
|
+
database computes it and rejects any value). So a table whose only
|
|
296
|
+
caller-supplied column is `body` is inserted with just `{ "body": "..." }`.
|
|
255
297
|
- `update_<table>` and `delete_<table>` change one row: the filter must
|
|
256
298
|
include `eq` on every primary-key column (more filters only narrow it).
|
|
257
299
|
A filter that happens to match everything (`id neq -1`, `item like %`) is
|
|
@@ -357,9 +399,9 @@ exits 1 if anything failed, so you can run it in CI.
|
|
|
357
399
|
readable. It lists which tables became tools, and warns about tables that
|
|
358
400
|
never will: RLS off, or RLS on but no `SELECT` for `authenticated` (with the
|
|
359
401
|
`grant` to run). It warns about writes in the function's config the role
|
|
360
|
-
isn't granted, about
|
|
361
|
-
grants, and about tables whose missing
|
|
362
|
-
descriptions.
|
|
402
|
+
isn't granted, about an older `exposify_meta()` that doesn't report
|
|
403
|
+
grants or identity/generated columns, and about tables whose missing
|
|
404
|
+
comments leave tools with thin descriptions.
|
|
363
405
|
- **Deployed function:** speaks MCP to `<project>/functions/v1/mcp`
|
|
364
406
|
(`initialize`, then `tools/list`) and reports the tool count. If the
|
|
365
407
|
function registered `exposify://diagnostics`, which it only does when
|
|
@@ -481,7 +523,7 @@ Supabase user JWTs expire (default 1h) and their refresh tokens rotate; a plain
|
|
|
481
523
|
MCP client can hold a bearer token but can't refresh it. Exposify's hosted
|
|
482
524
|
broker holds each user's session, refreshes it, and swaps the agent's
|
|
483
525
|
long-lived connection token (`extb_...`) for a live Supabase JWT on every
|
|
484
|
-
call ([
|
|
526
|
+
call (see the [hosted broker guide](https://github.com/ayadagain/agentready/blob/main/docs/hosted-broker.md)). `init` sets it
|
|
485
527
|
up (see "Using the hosted broker" above); by hand:
|
|
486
528
|
|
|
487
529
|
```sh
|
|
@@ -580,7 +622,7 @@ the function prints whether MCP is up. It shows in `supabase functions serve`
|
|
|
580
622
|
locally and in the function's logs in production:
|
|
581
623
|
|
|
582
624
|
```
|
|
583
|
-
[
|
|
625
|
+
[exposify] MCP ready at https://<ref>.supabase.co/functions/v1/mcp: 9 tools (5 query, 2 write, 1 rpc, search_docs), 3 docs indexed, auth: passthrough (caller's JWT)
|
|
584
626
|
```
|
|
585
627
|
|
|
586
628
|
- **Manifest fallback:** the same line, marked "from the bundled manifest",
|
package/dist/cli.js
CHANGED
|
@@ -76,6 +76,10 @@ function columnsFromOpenApi(doc, table) {
|
|
|
76
76
|
type: TYPE_MAP[prop.type ?? "string"] ?? "string",
|
|
77
77
|
format: prop.format,
|
|
78
78
|
required: required.has(name),
|
|
79
|
+
// A column with a DEFAULT doesn't have to be supplied on insert even when
|
|
80
|
+
// it's NOT NULL (the database fills it in). identity/generated aren't in
|
|
81
|
+
// PostgREST's OpenAPI -- introspect.ts adds those from exposify_meta().
|
|
82
|
+
hasDefault: prop.default !== void 0,
|
|
79
83
|
isPrimaryKey: isPrimaryKey(prop),
|
|
80
84
|
// Falls back to the OpenAPI-embedded comment (PostgREST mirrors `COMMENT
|
|
81
85
|
// ON COLUMN` into `description`, then appends its own "Note:\nThis is a
|
|
@@ -110,7 +114,13 @@ function introspectTables(openapi, metaRows) {
|
|
|
110
114
|
...fromOpenApi,
|
|
111
115
|
// exposify_meta()'s comment wins over OpenAPI's mirrored copy (spec:
|
|
112
116
|
// "Column comments → param descriptions" sourced from the migration).
|
|
113
|
-
description: row.column_comment ?? fromOpenApi.description
|
|
117
|
+
description: row.column_comment ?? fromOpenApi.description,
|
|
118
|
+
// identity/generated aren't in PostgREST's OpenAPI; they come from
|
|
119
|
+
// exposify_meta() (the `_v3` migration). Absent on the older function,
|
|
120
|
+
// leaving these unset -- the insert/update shapes then fall back to
|
|
121
|
+
// defaults alone, which is the pre-fix behaviour.
|
|
122
|
+
...row.identity_kind ? { identity: row.identity_kind } : {},
|
|
123
|
+
...row.is_generated === true ? { generated: true } : {}
|
|
114
124
|
});
|
|
115
125
|
}
|
|
116
126
|
if (columns.length === 0) continue;
|
|
@@ -569,7 +579,7 @@ function warnOnce(message) {
|
|
|
569
579
|
if (warned.has(message)) return;
|
|
570
580
|
warned.add(message);
|
|
571
581
|
try {
|
|
572
|
-
console.warn(`[
|
|
582
|
+
console.warn(`[exposify] ${message}`);
|
|
573
583
|
} catch {
|
|
574
584
|
}
|
|
575
585
|
}
|
|
@@ -741,14 +751,14 @@ async function runDoctor(deps) {
|
|
|
741
751
|
sections.push({ title: "Project", checks: project });
|
|
742
752
|
const files = [];
|
|
743
753
|
const migrations = await deps.listDir(MIGRATIONS_DIR);
|
|
744
|
-
const migration = findExistingMigration(migrations, "
|
|
745
|
-
const legacyMigration = findExistingMigration(migrations, "_exposify_meta.sql");
|
|
754
|
+
const migration = findExistingMigration(migrations, "_exposify_meta_v3.sql");
|
|
755
|
+
const legacyMigration = findExistingMigration(migrations, "_exposify_meta_v2.sql") || findExistingMigration(migrations, "_exposify_meta.sql");
|
|
746
756
|
files.push(
|
|
747
757
|
migration ? { status: "pass", message: `migration ${MIGRATIONS_DIR}/${migration}` } : legacyMigration ? {
|
|
748
758
|
status: "warn",
|
|
749
|
-
message: `migration ${MIGRATIONS_DIR}/${legacyMigration} is
|
|
750
|
-
fix: "npx @exposify/supabase init (it adds *
|
|
751
|
-
} : { status: "fail", message: "no *
|
|
759
|
+
message: `migration ${MIGRATIONS_DIR}/${legacyMigration} is an older exposify_meta(), which doesn't report identity/generated columns`,
|
|
760
|
+
fix: "npx @exposify/supabase init (it adds *_exposify_meta_v3.sql), then supabase db push"
|
|
761
|
+
} : { status: "fail", message: "no *_exposify_meta_v3.sql migration", fix: "npx @exposify/supabase init" }
|
|
752
762
|
);
|
|
753
763
|
const functionSource = await deps.readText(FUNCTION_PATH);
|
|
754
764
|
const authMode = functionSource === null ? null : detectAuthMode(functionSource);
|
|
@@ -1000,7 +1010,8 @@ var FUNCTION_DIR = "supabase/functions/mcp";
|
|
|
1000
1010
|
var FUNCTION_PATH2 = `${FUNCTION_DIR}/index.ts`;
|
|
1001
1011
|
var MIGRATIONS_DIR2 = "supabase/migrations";
|
|
1002
1012
|
var CONFIG_TOML2 = "supabase/config.toml";
|
|
1003
|
-
var META_SUFFIX = "
|
|
1013
|
+
var META_SUFFIX = "_exposify_meta_v3.sql";
|
|
1014
|
+
var META_SUFFIX_V2 = "_exposify_meta_v2.sql";
|
|
1004
1015
|
var LEGACY_META_SUFFIX = "_exposify_meta.sql";
|
|
1005
1016
|
var COMMENTS_SUFFIX = "_exposify_comments.sql";
|
|
1006
1017
|
function authLabel(config) {
|
|
@@ -1036,7 +1047,7 @@ function planInit(input) {
|
|
|
1036
1047
|
changes.push({
|
|
1037
1048
|
path: `${MIGRATIONS_DIR2}/${name}`,
|
|
1038
1049
|
action: "create",
|
|
1039
|
-
summary: findExistingMigration(input.migrations, LEGACY_META_SUFFIX) ? "exposify_meta() update: it also reports
|
|
1050
|
+
summary: findExistingMigration(input.migrations, LEGACY_META_SUFFIX) || findExistingMigration(input.migrations, META_SUFFIX_V2) ? "exposify_meta() update: it also reports identity and generated columns" : "exposify_meta() migration",
|
|
1040
1051
|
content: input.metaMigrationSql,
|
|
1041
1052
|
effect: "push"
|
|
1042
1053
|
});
|
package/dist/index.cjs
CHANGED
|
@@ -107,7 +107,7 @@ function warnOnce(message) {
|
|
|
107
107
|
if (warned.has(message)) return;
|
|
108
108
|
warned.add(message);
|
|
109
109
|
try {
|
|
110
|
-
console.warn(`[
|
|
110
|
+
console.warn(`[exposify] ${message}`);
|
|
111
111
|
} catch {
|
|
112
112
|
}
|
|
113
113
|
}
|
|
@@ -258,6 +258,10 @@ function columnsFromOpenApi(doc, table) {
|
|
|
258
258
|
type: TYPE_MAP[prop.type ?? "string"] ?? "string",
|
|
259
259
|
format: prop.format,
|
|
260
260
|
required: required.has(name),
|
|
261
|
+
// A column with a DEFAULT doesn't have to be supplied on insert even when
|
|
262
|
+
// it's NOT NULL (the database fills it in). identity/generated aren't in
|
|
263
|
+
// PostgREST's OpenAPI -- introspect.ts adds those from exposify_meta().
|
|
264
|
+
hasDefault: prop.default !== void 0,
|
|
261
265
|
isPrimaryKey: isPrimaryKey(prop),
|
|
262
266
|
// Falls back to the OpenAPI-embedded comment (PostgREST mirrors `COMMENT
|
|
263
267
|
// ON COLUMN` into `description`, then appends its own "Note:\nThis is a
|
|
@@ -302,7 +306,13 @@ function introspectTables(openapi, metaRows) {
|
|
|
302
306
|
...fromOpenApi,
|
|
303
307
|
// exposify_meta()'s comment wins over OpenAPI's mirrored copy (spec:
|
|
304
308
|
// "Column comments → param descriptions" sourced from the migration).
|
|
305
|
-
description: row.column_comment ?? fromOpenApi.description
|
|
309
|
+
description: row.column_comment ?? fromOpenApi.description,
|
|
310
|
+
// identity/generated aren't in PostgREST's OpenAPI; they come from
|
|
311
|
+
// exposify_meta() (the `_v3` migration). Absent on the older function,
|
|
312
|
+
// leaving these unset -- the insert/update shapes then fall back to
|
|
313
|
+
// defaults alone, which is the pre-fix behaviour.
|
|
314
|
+
...row.identity_kind ? { identity: row.identity_kind } : {},
|
|
315
|
+
...row.is_generated === true ? { generated: true } : {}
|
|
306
316
|
});
|
|
307
317
|
}
|
|
308
318
|
if (columns.length === 0) continue;
|
|
@@ -527,11 +537,16 @@ function registerQueryTool(server, table, client, cfg, taken) {
|
|
|
527
537
|
}
|
|
528
538
|
);
|
|
529
539
|
}
|
|
540
|
+
function isDatabaseGenerated(col) {
|
|
541
|
+
return col.identity === "always" || col.generated === true;
|
|
542
|
+
}
|
|
530
543
|
function insertBodyShape(columns) {
|
|
531
544
|
const shape = {};
|
|
532
545
|
for (const col of columns) {
|
|
546
|
+
if (isDatabaseGenerated(col)) continue;
|
|
533
547
|
let field = columnToZod(col);
|
|
534
|
-
|
|
548
|
+
const databaseFillsIn = col.hasDefault === true || col.identity === "default";
|
|
549
|
+
if (!col.required || databaseFillsIn) field = field.optional();
|
|
535
550
|
shape[col.name] = field;
|
|
536
551
|
}
|
|
537
552
|
return shape;
|
|
@@ -582,7 +597,10 @@ function keyFilterShape(table, verb) {
|
|
|
582
597
|
function registerUpdateTool(server, table, client, taken) {
|
|
583
598
|
const name = toolName("update", table.name, taken);
|
|
584
599
|
const setShape = {};
|
|
585
|
-
for (const col of table.columns)
|
|
600
|
+
for (const col of table.columns) {
|
|
601
|
+
if (isDatabaseGenerated(col)) continue;
|
|
602
|
+
setShape[col.name] = columnToZod(col).optional();
|
|
603
|
+
}
|
|
586
604
|
const keys = keyColumns(table);
|
|
587
605
|
server.registerTool(
|
|
588
606
|
name,
|
|
@@ -737,7 +755,7 @@ function createSupabaseOAuthGate(oauth, supabaseUrl, fetchImpl) {
|
|
|
737
755
|
|
|
738
756
|
// src/startup.ts
|
|
739
757
|
var import_core4 = require("@exposify/core");
|
|
740
|
-
var LOG_PREFIX = "[
|
|
758
|
+
var LOG_PREFIX = "[exposify]";
|
|
741
759
|
var INTROSPECTION_FIX = "check that SUPABASE_SERVICE_ROLE_KEY is available to the function and the exposify_meta migration is applied (`supabase db push`); the reason is logged above. To survive this, bundle a manifest with `npx @exposify/supabase generate`";
|
|
742
760
|
function supabaseToolsPhrase(ladder, cfg, hasDocs) {
|
|
743
761
|
const query = ladder.tables.length;
|
|
@@ -856,7 +874,7 @@ function aiReady(options = {}) {
|
|
|
856
874
|
manifest: options.manifest
|
|
857
875
|
}).then((ladder) => {
|
|
858
876
|
for (const w of ladder.warnings) {
|
|
859
|
-
console.error(`[
|
|
877
|
+
console.error(`[exposify] degradation ladder: ${w.rung}: ${w.reason}`);
|
|
860
878
|
}
|
|
861
879
|
return ladder;
|
|
862
880
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -7,7 +7,19 @@ interface ColumnSchema {
|
|
|
7
7
|
type: ColumnType;
|
|
8
8
|
/** OpenAPI `format`, e.g. "uuid", "timestamp with time zone", "bigint". */
|
|
9
9
|
format?: string;
|
|
10
|
+
/** NOT NULL per PostgREST's OpenAPI `required`. Whether it must be *given* on insert is a separate question -- see `hasDefault`/`identity` and server.ts's `insertBodyShape`. */
|
|
10
11
|
required: boolean;
|
|
12
|
+
/** The OpenAPI property carried a `default`, so the database fills the column in when it's omitted -- optional on insert even when NOT NULL. */
|
|
13
|
+
hasDefault?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* An identity column (`GENERATED ... AS IDENTITY`), from `exposify_meta()`.
|
|
16
|
+
* `"always"` rejects any supplied value (left out of insert/update entirely);
|
|
17
|
+
* `"default"` is accepted but optional. Absent on a non-identity column, and
|
|
18
|
+
* on every column when the database still has the pre-identity `exposify_meta()`.
|
|
19
|
+
*/
|
|
20
|
+
identity?: "always" | "default";
|
|
21
|
+
/** A generated column (`GENERATED ALWAYS AS (...) STORED`), from `exposify_meta()`: the database computes it, so it's left out of insert/update. */
|
|
22
|
+
generated?: boolean;
|
|
11
23
|
/** From `exposify_meta()`'s `column_comment` (a `COMMENT ON COLUMN`), not OpenAPI. */
|
|
12
24
|
description?: string;
|
|
13
25
|
isPrimaryKey?: boolean;
|
|
@@ -43,6 +55,10 @@ interface MetaRow {
|
|
|
43
55
|
can_insert?: boolean;
|
|
44
56
|
can_update?: boolean;
|
|
45
57
|
can_delete?: boolean;
|
|
58
|
+
/** `pg_attribute.attidentity` mapped: `"always"` ('a'), `"default"` ('d'), or null/absent. Absent before the `_v3` migration. */
|
|
59
|
+
identity_kind?: "always" | "default" | null;
|
|
60
|
+
/** `pg_attribute.attgenerated <> ''` -- a generated (stored) column. Absent before the `_v3` migration. */
|
|
61
|
+
is_generated?: boolean;
|
|
46
62
|
}
|
|
47
63
|
/** Registered sibling edge function exposed as a tool. */
|
|
48
64
|
interface FunctionToolConfig {
|
|
@@ -189,6 +205,8 @@ interface PostgrestColumnProperty {
|
|
|
189
205
|
type?: string;
|
|
190
206
|
format?: string;
|
|
191
207
|
description?: string;
|
|
208
|
+
/** PostgREST emits the column's `DEFAULT` expression here (e.g. `"now()"`, `"auth.uid()"`) when it has one. We only care whether it's present. */
|
|
209
|
+
default?: unknown;
|
|
192
210
|
}
|
|
193
211
|
/**
|
|
194
212
|
* Extracts column schemas for one table from a PostgREST OpenAPI document.
|
package/dist/index.d.ts
CHANGED
|
@@ -7,7 +7,19 @@ interface ColumnSchema {
|
|
|
7
7
|
type: ColumnType;
|
|
8
8
|
/** OpenAPI `format`, e.g. "uuid", "timestamp with time zone", "bigint". */
|
|
9
9
|
format?: string;
|
|
10
|
+
/** NOT NULL per PostgREST's OpenAPI `required`. Whether it must be *given* on insert is a separate question -- see `hasDefault`/`identity` and server.ts's `insertBodyShape`. */
|
|
10
11
|
required: boolean;
|
|
12
|
+
/** The OpenAPI property carried a `default`, so the database fills the column in when it's omitted -- optional on insert even when NOT NULL. */
|
|
13
|
+
hasDefault?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* An identity column (`GENERATED ... AS IDENTITY`), from `exposify_meta()`.
|
|
16
|
+
* `"always"` rejects any supplied value (left out of insert/update entirely);
|
|
17
|
+
* `"default"` is accepted but optional. Absent on a non-identity column, and
|
|
18
|
+
* on every column when the database still has the pre-identity `exposify_meta()`.
|
|
19
|
+
*/
|
|
20
|
+
identity?: "always" | "default";
|
|
21
|
+
/** A generated column (`GENERATED ALWAYS AS (...) STORED`), from `exposify_meta()`: the database computes it, so it's left out of insert/update. */
|
|
22
|
+
generated?: boolean;
|
|
11
23
|
/** From `exposify_meta()`'s `column_comment` (a `COMMENT ON COLUMN`), not OpenAPI. */
|
|
12
24
|
description?: string;
|
|
13
25
|
isPrimaryKey?: boolean;
|
|
@@ -43,6 +55,10 @@ interface MetaRow {
|
|
|
43
55
|
can_insert?: boolean;
|
|
44
56
|
can_update?: boolean;
|
|
45
57
|
can_delete?: boolean;
|
|
58
|
+
/** `pg_attribute.attidentity` mapped: `"always"` ('a'), `"default"` ('d'), or null/absent. Absent before the `_v3` migration. */
|
|
59
|
+
identity_kind?: "always" | "default" | null;
|
|
60
|
+
/** `pg_attribute.attgenerated <> ''` -- a generated (stored) column. Absent before the `_v3` migration. */
|
|
61
|
+
is_generated?: boolean;
|
|
46
62
|
}
|
|
47
63
|
/** Registered sibling edge function exposed as a tool. */
|
|
48
64
|
interface FunctionToolConfig {
|
|
@@ -189,6 +205,8 @@ interface PostgrestColumnProperty {
|
|
|
189
205
|
type?: string;
|
|
190
206
|
format?: string;
|
|
191
207
|
description?: string;
|
|
208
|
+
/** PostgREST emits the column's `DEFAULT` expression here (e.g. `"now()"`, `"auth.uid()"`) when it has one. We only care whether it's present. */
|
|
209
|
+
default?: unknown;
|
|
192
210
|
}
|
|
193
211
|
/**
|
|
194
212
|
* Extracts column schemas for one table from a PostgREST OpenAPI document.
|
package/dist/index.js
CHANGED
|
@@ -70,7 +70,7 @@ function warnOnce(message) {
|
|
|
70
70
|
if (warned.has(message)) return;
|
|
71
71
|
warned.add(message);
|
|
72
72
|
try {
|
|
73
|
-
console.warn(`[
|
|
73
|
+
console.warn(`[exposify] ${message}`);
|
|
74
74
|
} catch {
|
|
75
75
|
}
|
|
76
76
|
}
|
|
@@ -221,6 +221,10 @@ function columnsFromOpenApi(doc, table) {
|
|
|
221
221
|
type: TYPE_MAP[prop.type ?? "string"] ?? "string",
|
|
222
222
|
format: prop.format,
|
|
223
223
|
required: required.has(name),
|
|
224
|
+
// A column with a DEFAULT doesn't have to be supplied on insert even when
|
|
225
|
+
// it's NOT NULL (the database fills it in). identity/generated aren't in
|
|
226
|
+
// PostgREST's OpenAPI -- introspect.ts adds those from exposify_meta().
|
|
227
|
+
hasDefault: prop.default !== void 0,
|
|
224
228
|
isPrimaryKey: isPrimaryKey(prop),
|
|
225
229
|
// Falls back to the OpenAPI-embedded comment (PostgREST mirrors `COMMENT
|
|
226
230
|
// ON COLUMN` into `description`, then appends its own "Note:\nThis is a
|
|
@@ -265,7 +269,13 @@ function introspectTables(openapi, metaRows) {
|
|
|
265
269
|
...fromOpenApi,
|
|
266
270
|
// exposify_meta()'s comment wins over OpenAPI's mirrored copy (spec:
|
|
267
271
|
// "Column comments → param descriptions" sourced from the migration).
|
|
268
|
-
description: row.column_comment ?? fromOpenApi.description
|
|
272
|
+
description: row.column_comment ?? fromOpenApi.description,
|
|
273
|
+
// identity/generated aren't in PostgREST's OpenAPI; they come from
|
|
274
|
+
// exposify_meta() (the `_v3` migration). Absent on the older function,
|
|
275
|
+
// leaving these unset -- the insert/update shapes then fall back to
|
|
276
|
+
// defaults alone, which is the pre-fix behaviour.
|
|
277
|
+
...row.identity_kind ? { identity: row.identity_kind } : {},
|
|
278
|
+
...row.is_generated === true ? { generated: true } : {}
|
|
269
279
|
});
|
|
270
280
|
}
|
|
271
281
|
if (columns.length === 0) continue;
|
|
@@ -494,11 +504,16 @@ function registerQueryTool(server, table, client, cfg, taken) {
|
|
|
494
504
|
}
|
|
495
505
|
);
|
|
496
506
|
}
|
|
507
|
+
function isDatabaseGenerated(col) {
|
|
508
|
+
return col.identity === "always" || col.generated === true;
|
|
509
|
+
}
|
|
497
510
|
function insertBodyShape(columns) {
|
|
498
511
|
const shape = {};
|
|
499
512
|
for (const col of columns) {
|
|
513
|
+
if (isDatabaseGenerated(col)) continue;
|
|
500
514
|
let field = columnToZod(col);
|
|
501
|
-
|
|
515
|
+
const databaseFillsIn = col.hasDefault === true || col.identity === "default";
|
|
516
|
+
if (!col.required || databaseFillsIn) field = field.optional();
|
|
502
517
|
shape[col.name] = field;
|
|
503
518
|
}
|
|
504
519
|
return shape;
|
|
@@ -549,7 +564,10 @@ function keyFilterShape(table, verb) {
|
|
|
549
564
|
function registerUpdateTool(server, table, client, taken) {
|
|
550
565
|
const name = toolName("update", table.name, taken);
|
|
551
566
|
const setShape = {};
|
|
552
|
-
for (const col of table.columns)
|
|
567
|
+
for (const col of table.columns) {
|
|
568
|
+
if (isDatabaseGenerated(col)) continue;
|
|
569
|
+
setShape[col.name] = columnToZod(col).optional();
|
|
570
|
+
}
|
|
553
571
|
const keys = keyColumns(table);
|
|
554
572
|
server.registerTool(
|
|
555
573
|
name,
|
|
@@ -704,7 +722,7 @@ function createSupabaseOAuthGate(oauth, supabaseUrl, fetchImpl) {
|
|
|
704
722
|
|
|
705
723
|
// src/startup.ts
|
|
706
724
|
import { formatFailed, formatReady, formatWarning } from "@exposify/core";
|
|
707
|
-
var LOG_PREFIX = "[
|
|
725
|
+
var LOG_PREFIX = "[exposify]";
|
|
708
726
|
var INTROSPECTION_FIX = "check that SUPABASE_SERVICE_ROLE_KEY is available to the function and the exposify_meta migration is applied (`supabase db push`); the reason is logged above. To survive this, bundle a manifest with `npx @exposify/supabase generate`";
|
|
709
727
|
function supabaseToolsPhrase(ladder, cfg, hasDocs) {
|
|
710
728
|
const query = ladder.tables.length;
|
|
@@ -823,7 +841,7 @@ function aiReady(options = {}) {
|
|
|
823
841
|
manifest: options.manifest
|
|
824
842
|
}).then((ladder) => {
|
|
825
843
|
for (const w of ladder.warnings) {
|
|
826
|
-
console.error(`[
|
|
844
|
+
console.error(`[exposify] degradation ladder: ${w.rung}: ${w.reason}`);
|
|
827
845
|
}
|
|
828
846
|
return ladder;
|
|
829
847
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exposify/supabase",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.15",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"prepack": "yarn build"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@exposify/core": "0.3.
|
|
30
|
+
"@exposify/core": "0.3.19",
|
|
31
31
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
32
32
|
"zod": "^3.24.0"
|
|
33
33
|
},
|
package/sql/exposify_meta.sql
CHANGED
|
@@ -28,6 +28,12 @@
|
|
|
28
28
|
-- The grant columns were added later, as a new migration
|
|
29
29
|
-- (`<timestamp>_exposify_meta_v2.sql`): adding output columns changes the
|
|
30
30
|
-- function's return type, which `create or replace` can't do, hence the drop.
|
|
31
|
+
-- The `identity_kind`/`is_generated` columns were added later still, as a
|
|
32
|
+
-- third migration (`<timestamp>_exposify_meta_v3.sql`), for the same reason:
|
|
33
|
+
-- they tell the insert/update tools which columns the database computes
|
|
34
|
+
-- itself (an identity or generated column) so they aren't demanded or even
|
|
35
|
+
-- offered -- otherwise `insert_<table>` asks for a `generated always as
|
|
36
|
+
-- identity` primary key and PostgREST rejects any value for it.
|
|
31
37
|
drop function if exists public.exposify_meta();
|
|
32
38
|
create function public.exposify_meta()
|
|
33
39
|
returns table (
|
|
@@ -39,7 +45,9 @@ returns table (
|
|
|
39
45
|
can_select boolean,
|
|
40
46
|
can_insert boolean,
|
|
41
47
|
can_update boolean,
|
|
42
|
-
can_delete boolean
|
|
48
|
+
can_delete boolean,
|
|
49
|
+
identity_kind text,
|
|
50
|
+
is_generated boolean
|
|
43
51
|
)
|
|
44
52
|
language sql
|
|
45
53
|
security definer
|
|
@@ -55,7 +63,11 @@ as $$
|
|
|
55
63
|
has_any_column_privilege('authenticated', c.oid, 'SELECT') as can_select,
|
|
56
64
|
has_any_column_privilege('authenticated', c.oid, 'INSERT') as can_insert,
|
|
57
65
|
has_any_column_privilege('authenticated', c.oid, 'UPDATE') as can_update,
|
|
58
|
-
has_table_privilege('authenticated', c.oid, 'DELETE') as can_delete
|
|
66
|
+
has_table_privilege('authenticated', c.oid, 'DELETE') as can_delete,
|
|
67
|
+
-- 'a' = GENERATED ALWAYS AS IDENTITY, 'd' = ... BY DEFAULT, '' = not an identity column.
|
|
68
|
+
case a.attidentity when 'a' then 'always' when 'd' then 'default' else null end as identity_kind,
|
|
69
|
+
-- 's' = GENERATED ALWAYS AS (...) STORED; '' = ordinary column.
|
|
70
|
+
(a.attgenerated <> '') as is_generated
|
|
59
71
|
from pg_catalog.pg_class c
|
|
60
72
|
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
|
61
73
|
join pg_catalog.pg_attribute a on a.attrelid = c.oid
|