@plutocms/supabase 0.7.1 → 0.8.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/CHANGELOG.md +14 -0
- package/FEATURES.md +3 -0
- package/app/pages/admin/migrations.vue +44 -1
- package/app/pages/admin.vue +5 -13
- package/app/plugins/pluto-permissions-sync.ts +38 -0
- package/package.json +2 -2
- package/server/plugins/content-adapter.ts +7 -0
- package/server/utils/admin-guard.ts +48 -19
- package/server/utils/content-adapter.ts +336 -0
- package/server/utils/migrations.ts +0 -0
- package/server/utils/pending-migrations.ts +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.0](https://github.com/plutocms/supabase/compare/v0.7.2...v0.8.0) (2026-09-13)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **content:** add Supabase-backed PlutoContentAdapter (wave 4) ([#60](https://github.com/plutocms/supabase/issues/60)) ([834136d](https://github.com/plutocms/supabase/commit/834136d39f2297ad771cf00de5bc33f0e811a4b9))
|
|
9
|
+
|
|
10
|
+
## [0.7.2](https://github.com/plutocms/supabase/compare/v0.7.1...v0.7.2) (2026-09-13)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **permissions:** make requireAdmin safe on a zero-migration database ([#58](https://github.com/plutocms/supabase/issues/58)) ([a83f6d0](https://github.com/plutocms/supabase/commit/a83f6d0bfa3ae150df5b90a06fcd29fe437ad9b7))
|
|
16
|
+
|
|
3
17
|
## [0.7.1](https://github.com/plutocms/supabase/compare/v0.7.0...v0.7.1) (2026-09-13)
|
|
4
18
|
|
|
5
19
|
|
package/FEATURES.md
CHANGED
|
@@ -10,3 +10,6 @@ policies shared by every project here.
|
|
|
10
10
|
after initial setup. @.claude/skills/layer-migrations/SKILL.md
|
|
11
11
|
- Permissions storage: role-based capabilities backing server route guards and RLS policies.
|
|
12
12
|
@.claude/skills/permissions-storage/SKILL.md
|
|
13
|
+
- Content adapter: a Supabase/PostgREST-backed `PlutoContentAdapter`, plugging any layer's
|
|
14
|
+
declared content type into `@plutocms/pluto`'s generic content routes.
|
|
15
|
+
@.claude/skills/content-adapter/SKILL.md
|
|
@@ -30,6 +30,13 @@ const connectionForm = ref({
|
|
|
30
30
|
})
|
|
31
31
|
|
|
32
32
|
const isRunning = ref(false)
|
|
33
|
+
// True only while waitForServerAndRefresh is polling through a dev-server
|
|
34
|
+
// restart. useMigrations().status reads as null on every failed poll in
|
|
35
|
+
// that window (see its own doc comment), which would otherwise leave the
|
|
36
|
+
// page blank below the title — the toasts alone are easy to miss. The
|
|
37
|
+
// template shows a persistent banner instead, keyed on this flag, so the
|
|
38
|
+
// page always explains what is happening instead of going quiet.
|
|
39
|
+
const isReconnecting = ref(false)
|
|
33
40
|
const lastRun = ref<Awaited<ReturnType<typeof runMigrations>> | null>(null)
|
|
34
41
|
// Kept only in this page's own memory, only to show the manual .env step if
|
|
35
42
|
// persisting fails — see EnvPersistWarning.vue. Never sent anywhere new.
|
|
@@ -118,8 +125,12 @@ async function applyMigrations(useForm: boolean) {
|
|
|
118
125
|
color: 'info',
|
|
119
126
|
})
|
|
120
127
|
|
|
128
|
+
isReconnecting.value = true
|
|
129
|
+
|
|
121
130
|
const recovered = await waitForServerAndRefresh()
|
|
122
131
|
|
|
132
|
+
isReconnecting.value = false
|
|
133
|
+
|
|
123
134
|
toast.add(
|
|
124
135
|
recovered
|
|
125
136
|
? {
|
|
@@ -163,7 +174,19 @@ async function applyMigrations(useForm: boolean) {
|
|
|
163
174
|
<AdminView>
|
|
164
175
|
<h1 class="text-3xl font-bold lg:text-4xl">Migrations</h1>
|
|
165
176
|
|
|
166
|
-
<
|
|
177
|
+
<UAlert
|
|
178
|
+
v-if="isReconnecting"
|
|
179
|
+
color="info"
|
|
180
|
+
variant="outline"
|
|
181
|
+
icon="lucide:refresh-cw"
|
|
182
|
+
title="Dev server restarting"
|
|
183
|
+
description="Saving a new connection string restarts the dev server. Reconnecting — this page will update on its own once it's back."
|
|
184
|
+
/>
|
|
185
|
+
|
|
186
|
+
<div
|
|
187
|
+
v-else-if="fetchStatus === 'pending'"
|
|
188
|
+
class="flex items-center gap-x-2"
|
|
189
|
+
>
|
|
167
190
|
<Icon name="svg-spinners:ring-resize" />
|
|
168
191
|
<span>Loading migration status…</span>
|
|
169
192
|
</div>
|
|
@@ -340,5 +363,25 @@ async function applyMigrations(useForm: boolean) {
|
|
|
340
363
|
</div>
|
|
341
364
|
</UCard>
|
|
342
365
|
</template>
|
|
366
|
+
|
|
367
|
+
<UAlert
|
|
368
|
+
v-else
|
|
369
|
+
color="error"
|
|
370
|
+
variant="outline"
|
|
371
|
+
icon="lucide:circle-x"
|
|
372
|
+
title="Could not load migration status"
|
|
373
|
+
description="This isn't the dev-server-restart case above — something else went wrong reading migration status. Check the server console, then try again."
|
|
374
|
+
>
|
|
375
|
+
<template #actions>
|
|
376
|
+
<UButton
|
|
377
|
+
color="error"
|
|
378
|
+
variant="outline"
|
|
379
|
+
icon="lucide:refresh-cw"
|
|
380
|
+
@click="refresh()"
|
|
381
|
+
>
|
|
382
|
+
Retry
|
|
383
|
+
</UButton>
|
|
384
|
+
</template>
|
|
385
|
+
</UAlert>
|
|
343
386
|
</AdminView>
|
|
344
387
|
</template>
|
package/app/pages/admin.vue
CHANGED
|
@@ -8,19 +8,11 @@ const route = useRoute()
|
|
|
8
8
|
const { isLoggedIn, logout, allowedUnauthenticatedPaths } = await useAuth()
|
|
9
9
|
const toast = useToast()
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (loggedIn) {
|
|
17
|
-
loadPermissions()
|
|
18
|
-
} else {
|
|
19
|
-
clearPermissions()
|
|
20
|
-
}
|
|
21
|
-
},
|
|
22
|
-
{ immediate: true }
|
|
23
|
-
)
|
|
11
|
+
// Loading/clearing permissions on login/logout lives in
|
|
12
|
+
// app/plugins/pluto-permissions-sync.ts now, not here — that plugin runs
|
|
13
|
+
// on every page, not just this one, so a signed-in admin browsing a public
|
|
14
|
+
// page directly (no prior visit to /admin/** this session) still gets
|
|
15
|
+
// their capabilities loaded.
|
|
24
16
|
|
|
25
17
|
const visibility = useDocumentVisibility()
|
|
26
18
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads (or clears) the current user's capabilities whenever their session
|
|
3
|
+
* appears or disappears — app-wide, not page-scoped.
|
|
4
|
+
*
|
|
5
|
+
* This used to live as a `watch` inside `app/pages/admin.vue`. That only
|
|
6
|
+
* ran while `admin.vue` was the mounted page, so a signed-in admin loading
|
|
7
|
+
* a public page directly (a fresh navigation or a page reload, with no
|
|
8
|
+
* earlier visit to `/admin/**` in the same app instance) never triggered a
|
|
9
|
+
* load at all — `usePlutoPermissions().granted` stayed empty, and every
|
|
10
|
+
* `can()` check for that admin read as `false` on that page, even though
|
|
11
|
+
* the same admin's capability-gated UI worked correctly inside `/admin/**`.
|
|
12
|
+
* `NavbarAdminActions.vue`'s "Edit product" quick-link (shown while
|
|
13
|
+
* viewing a live `/product/**` page) is exactly the kind of capability-
|
|
14
|
+
* gated UI that lives outside `/admin/**` by design, so it needs this to
|
|
15
|
+
* fire on every page, not just admin ones.
|
|
16
|
+
*
|
|
17
|
+
* `useSupabaseSession()` (not `useAuth()`) on purpose: it is the same
|
|
18
|
+
* primitive `useAuth()`'s own `isLoggedIn` is built from
|
|
19
|
+
* (`computed(() => !!supabaseSession.value)`), and it is genuinely
|
|
20
|
+
* app-wide reactive state already (managed by `@nuxtjs/supabase`'s own
|
|
21
|
+
* plugin), so reading it directly here needs no extra composable surface.
|
|
22
|
+
*/
|
|
23
|
+
export default defineNuxtPlugin(() => {
|
|
24
|
+
const session = useSupabaseSession()
|
|
25
|
+
const { load, clear } = usePlutoPermissions()
|
|
26
|
+
|
|
27
|
+
watch(
|
|
28
|
+
session,
|
|
29
|
+
(current) => {
|
|
30
|
+
if (current) {
|
|
31
|
+
load()
|
|
32
|
+
} else {
|
|
33
|
+
clear()
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{ immediate: true }
|
|
37
|
+
)
|
|
38
|
+
})
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plutocms/supabase",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.0",
|
|
5
5
|
"trustedDependencies": [
|
|
6
6
|
"@parcel/watcher",
|
|
7
7
|
"@plutocms/pluto",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@nuxt/eslint": "^1.16.0",
|
|
40
40
|
"@nuxt/ui": "^4.9.0",
|
|
41
41
|
"@nuxtjs/supabase": "^2.0.9",
|
|
42
|
-
"@plutocms/pluto": "^0.
|
|
42
|
+
"@plutocms/pluto": "^0.8.1",
|
|
43
43
|
"@plutocms/utils": "^0.2.1",
|
|
44
44
|
"postgres": "^3.4.9",
|
|
45
45
|
"supabase": "^2.109.1"
|
|
@@ -4,25 +4,42 @@ import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
|
|
|
4
4
|
/**
|
|
5
5
|
* Guards a server route so only an admin can call it.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* `
|
|
9
|
-
* defined by `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
7
|
+
* Queries `public.profiles.is_admin` directly — deliberately NOT
|
|
8
|
+
* `public.is_admin()` or `public.has_capability()`. Both of those are
|
|
9
|
+
* defined by migrations (`002_admin_hardening.sql`,
|
|
10
|
+
* `004_roles_and_capabilities.sql` respectively); a genuinely fresh site
|
|
11
|
+
* that has never applied any migration beyond the original baseline
|
|
12
|
+
* schema — an expected, normal state, not a broken one — has neither
|
|
13
|
+
* function. `/api/migrations/*` (the only routes that call `requireAdmin`
|
|
14
|
+
* today) are the one place this actually bites: they are the routes that
|
|
15
|
+
* apply a pending migration, so they must work with zero migrations
|
|
16
|
+
* applied, not just with `002`+ already in place. An earlier version of
|
|
17
|
+
* this function called `public.is_admin()` on the theory that it "always
|
|
18
|
+
* exists" — true for a site that has applied at least `002`, false for a
|
|
19
|
+
* fresh one that has not, which is exactly the case that matters here.
|
|
20
|
+
* `profiles.is_admin` (the column) is part of the original baseline
|
|
21
|
+
* schema itself, before any of this project's layered migrations existed,
|
|
22
|
+
* so querying it directly has no migration dependency at all.
|
|
23
|
+
*
|
|
24
|
+
* The trade-off: a user granted the `admin` role only through
|
|
25
|
+
* `user_roles` (see `permissions-storage/SKILL.md`'s "Granting a role by
|
|
26
|
+
* hand"), with `profiles.is_admin` left `false`, cannot pass this check —
|
|
27
|
+
* only RLS and `has_capability()`-gated routes recognize a role-only
|
|
28
|
+
* admin. That is an acceptable, narrow limitation: `user_roles` cannot
|
|
29
|
+
* exist before `004` has been applied, so a role-only admin can only
|
|
30
|
+
* exist on a site that has already applied every current migration, at
|
|
31
|
+
* which point there is nothing left for that admin to need
|
|
32
|
+
* `/admin/migrations` for. If a future migration ever needs applying by
|
|
33
|
+
* a role-only admin, grant them `profiles.is_admin = true` too.
|
|
34
|
+
*
|
|
35
|
+
* `serverSupabaseUser` (from `@nuxtjs/supabase`, backed by
|
|
36
|
+
* `client.auth.getClaims()`) returns decoded JWT claims, not a Supabase
|
|
37
|
+
* `User` row — the claims object has no `id` field, only `sub`. Querying
|
|
38
|
+
* `.eq('id', user.id)` silently matches zero rows and looks exactly like
|
|
39
|
+
* "not an admin" even for a real admin. Always read `user.sub`.
|
|
21
40
|
*
|
|
22
41
|
* Throws a 401 if there is no logged-in user, or a 403 if the user is not
|
|
23
|
-
* an admin. Returns the user's claims on success.
|
|
24
|
-
* for the same user.sub vs user.id note — this function has the same
|
|
25
|
-
* shape but never reads either field either.
|
|
42
|
+
* an admin. Returns the user's claims on success.
|
|
26
43
|
*/
|
|
27
44
|
export async function requireAdmin(event: H3Event) {
|
|
28
45
|
const user = await serverSupabaseUser(event)
|
|
@@ -32,9 +49,21 @@ export async function requireAdmin(event: H3Event) {
|
|
|
32
49
|
}
|
|
33
50
|
|
|
34
51
|
const client = await serverSupabaseClient<Database>(event)
|
|
35
|
-
const { data, error } = await client
|
|
52
|
+
const { data: profile, error } = await client
|
|
53
|
+
.from('profiles')
|
|
54
|
+
.select('is_admin')
|
|
55
|
+
.eq('id', user.sub)
|
|
56
|
+
.single()
|
|
57
|
+
|
|
58
|
+
if (error) {
|
|
59
|
+
// Logged, never returned: the 403 below must not tell an untrusted
|
|
60
|
+
// caller whether it hit a real error (a missing column, a connection
|
|
61
|
+
// problem) or a genuine non-admin. Server logs are where that
|
|
62
|
+
// distinction has to live instead.
|
|
63
|
+
console.error('requireAdmin: profiles lookup failed:', error.message)
|
|
64
|
+
}
|
|
36
65
|
|
|
37
|
-
if (error ||
|
|
66
|
+
if (error || !profile?.is_admin) {
|
|
38
67
|
throw createError({ statusCode: 403, statusMessage: 'Your account is not an admin.' })
|
|
39
68
|
}
|
|
40
69
|
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import type { PostgrestError, SupabaseClient } from '@supabase/supabase-js'
|
|
2
|
+
import type { H3Event } from 'h3'
|
|
3
|
+
import { serverSupabaseClient } from '#supabase/server'
|
|
4
|
+
import { requireCapability } from './capability-guard'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A Supabase client for one request, deliberately untyped.
|
|
8
|
+
*
|
|
9
|
+
* A content type's table name (`type.source`) is a plain runtime string —
|
|
10
|
+
* it is not known until a layer declares a content type, in a different
|
|
11
|
+
* repo, at that repo's build time. `serverSupabaseClient` defaults to
|
|
12
|
+
* this app's own `Database` type (see `shared/types/supabase.ts`), whose
|
|
13
|
+
* `.from()` only accepts this repo's own table names as literals. Casting
|
|
14
|
+
* to the bare `SupabaseClient` type (whose own generic defaults to `any`)
|
|
15
|
+
* is the one, deliberate place this file loses compile-time table/column
|
|
16
|
+
* safety. See `.claude/skills/content-adapter/SKILL.md` for the full
|
|
17
|
+
* write-up.
|
|
18
|
+
*/
|
|
19
|
+
async function getClient(event: H3Event): Promise<SupabaseClient> {
|
|
20
|
+
return (await serverSupabaseClient(event)) as SupabaseClient
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolves the storage column for a content type's created/updated
|
|
25
|
+
* timestamp. Returns `undefined` when timestamps are disabled entirely
|
|
26
|
+
* (`type.timestamps === false`) or when this one timestamp is disabled
|
|
27
|
+
* (`type.timestamps[key] === false`).
|
|
28
|
+
*/
|
|
29
|
+
export function resolveTimestampColumn(
|
|
30
|
+
type: PlutoContentType,
|
|
31
|
+
key: 'created' | 'updated'
|
|
32
|
+
): string | undefined {
|
|
33
|
+
if (type.timestamps === false) {
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const configured = type.timestamps?.[key]
|
|
38
|
+
|
|
39
|
+
if (configured === false) {
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return configured ?? (key === 'created' ? 'created_at' : 'updated_at')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolves the storage column for a content type's status field. Returns
|
|
48
|
+
* `undefined` when status is disabled entirely (`type.status === false`
|
|
49
|
+
* or unset).
|
|
50
|
+
*/
|
|
51
|
+
export function resolveStatusColumn(type: PlutoContentType): string | undefined {
|
|
52
|
+
if (!type.status) {
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return type.status.column ?? 'status'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Maps a raw PostgREST error to an H3/Nuxt error safe to send to a
|
|
61
|
+
* client. Logs the raw error first, so the real message always reaches
|
|
62
|
+
* server logs even when the client-facing message is generic. No return
|
|
63
|
+
* type annotation: `createError`'s own return type (`NuxtError`) is left
|
|
64
|
+
* to flow through, rather than restated here.
|
|
65
|
+
*/
|
|
66
|
+
export function toContentError(error: PostgrestError) {
|
|
67
|
+
console.error('[content-adapter] Postgres error:', error)
|
|
68
|
+
|
|
69
|
+
// 23505: unique_violation.
|
|
70
|
+
if (error.code === '23505') {
|
|
71
|
+
return createError({
|
|
72
|
+
statusCode: 409,
|
|
73
|
+
statusMessage: 'A record with this value already exists.',
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 23503: foreign_key_violation.
|
|
78
|
+
if (error.code === '23503') {
|
|
79
|
+
return createError({
|
|
80
|
+
statusCode: 400,
|
|
81
|
+
statusMessage: 'This references a record that does not exist.',
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return createError({ statusCode: 500, statusMessage: error.message })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Resolves a field's storage column by name, warning and falling back to the raw name when the field is missing. This signals a misconfigured content type. */
|
|
89
|
+
function resolveFieldColumn(type: PlutoContentType, fieldName: string, context: string): string {
|
|
90
|
+
const field = type.fields.find((candidate) => candidate.name === fieldName)
|
|
91
|
+
|
|
92
|
+
if (field) {
|
|
93
|
+
return fieldColumn(field)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.warn(
|
|
97
|
+
`[content-adapter] Content type "${type.name}" has no field named "${fieldName}" (${context}). Falling back to the raw name as the column.`
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return fieldName
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function list(ctx: PlutoContentContext, query: PlutoContentQuery): Promise<PlutoContentListResult> {
|
|
104
|
+
const client = await getClient(ctx.event)
|
|
105
|
+
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
106
|
+
|
|
107
|
+
let builder = client.from(ctx.type.source).select('*', { count: 'exact' })
|
|
108
|
+
|
|
109
|
+
const statusColumn = resolveStatusColumn(ctx.type)
|
|
110
|
+
if (statusColumn && query.includeUnpublished !== true) {
|
|
111
|
+
const publishedValue = (ctx.type.status && ctx.type.status.publishedValue) || 'published'
|
|
112
|
+
builder = builder.eq(statusColumn, publishedValue)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (query.search) {
|
|
116
|
+
const titleColumn = resolveFieldColumn(ctx.type, ctx.type.titleField, 'titleField')
|
|
117
|
+
builder = builder.ilike(titleColumn, `%${query.search}%`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const sort = query.sort ?? ctx.type.defaultSort
|
|
121
|
+
if (sort) {
|
|
122
|
+
const sortColumn = resolveFieldColumn(ctx.type, sort.field, 'sort')
|
|
123
|
+
builder = builder.order(sortColumn, { ascending: sort.direction !== 'desc' })
|
|
124
|
+
} else {
|
|
125
|
+
const createdColumn = resolveTimestampColumn(ctx.type, 'created')
|
|
126
|
+
builder = builder.order(createdColumn ?? pkColumn, { ascending: false })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (query.offset !== undefined) {
|
|
130
|
+
const limit = query.limit ?? 50
|
|
131
|
+
builder = builder.range(query.offset, query.offset + limit - 1)
|
|
132
|
+
} else if (query.limit !== undefined) {
|
|
133
|
+
builder = builder.limit(query.limit)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const { data, error, count } = await builder
|
|
137
|
+
|
|
138
|
+
if (error) {
|
|
139
|
+
throw toContentError(error)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
data: (data ?? []).map((row: Record<string, unknown>) => mapColumnsToFields(ctx.type, row)),
|
|
144
|
+
total: count ?? undefined,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 22P02 (invalid_text_representation): the value doesn't parse as the
|
|
150
|
+
* primary key column's own SQL type — for example a slug like
|
|
151
|
+
* "hello-world" against a `bigint` id column. This is not a real error:
|
|
152
|
+
* it means "not found by id, at the database level", the same outcome as
|
|
153
|
+
* a clean empty result, and `get()` must fall through to trying the slug
|
|
154
|
+
* column exactly as it would for an empty result. Postgres reports a type
|
|
155
|
+
* mismatch as an error rather than zero rows, so this has to be handled
|
|
156
|
+
* separately from the empty-result case, not folded into it.
|
|
157
|
+
*/
|
|
158
|
+
function isInvalidIdShape(error: PostgrestError): boolean {
|
|
159
|
+
return error.code === '22P02'
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function get(ctx: PlutoContentContext, idOrSlug: string | number): Promise<PlutoContentItem | null> {
|
|
163
|
+
const client = await getClient(ctx.event)
|
|
164
|
+
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
165
|
+
|
|
166
|
+
const { data, error } = await client
|
|
167
|
+
.from(ctx.type.source)
|
|
168
|
+
.select('*')
|
|
169
|
+
.eq(pkColumn, idOrSlug)
|
|
170
|
+
.maybeSingle()
|
|
171
|
+
|
|
172
|
+
if (error && !isInvalidIdShape(error)) {
|
|
173
|
+
throw toContentError(error)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (data) {
|
|
177
|
+
return mapColumnsToFields(ctx.type, data)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (ctx.type.slug) {
|
|
181
|
+
const slugColumn = resolveFieldColumn(ctx.type, ctx.type.slug.field, 'slug.field')
|
|
182
|
+
|
|
183
|
+
const bySlug = await client
|
|
184
|
+
.from(ctx.type.source)
|
|
185
|
+
.select('*')
|
|
186
|
+
.eq(slugColumn, idOrSlug)
|
|
187
|
+
.maybeSingle()
|
|
188
|
+
|
|
189
|
+
if (bySlug.error) {
|
|
190
|
+
throw toContentError(bySlug.error)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (bySlug.data) {
|
|
194
|
+
return mapColumnsToFields(ctx.type, bySlug.data)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function create(ctx: PlutoContentContext, values: Record<string, unknown>): Promise<PlutoContentItem> {
|
|
202
|
+
const client = await getClient(ctx.event)
|
|
203
|
+
const row = mapFieldsToColumns(ctx.type, values)
|
|
204
|
+
|
|
205
|
+
// Status is not a declared field (see PlutoContentType.status — a
|
|
206
|
+
// separate, top-level concern from `fields`, unlike `slug`, which
|
|
207
|
+
// references a field also present in `fields`), so mapFieldsToColumns
|
|
208
|
+
// never sees it — it filters strictly to declared field names. A
|
|
209
|
+
// payload's status lives at the fixed conceptual key `status`,
|
|
210
|
+
// regardless of what `type.status.column` names in storage, mirroring
|
|
211
|
+
// how a field's own name (not its storage column) is the payload key
|
|
212
|
+
// everywhere else in this contract.
|
|
213
|
+
const statusColumn = resolveStatusColumn(ctx.type)
|
|
214
|
+
if (statusColumn) {
|
|
215
|
+
if (Object.hasOwn(values, 'status')) {
|
|
216
|
+
row[statusColumn] = values.status
|
|
217
|
+
} else if (ctx.type.status && ctx.type.status.default !== undefined) {
|
|
218
|
+
row[statusColumn] = ctx.type.status.default
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// A client never controls its own creation timestamp. Always stamp it,
|
|
223
|
+
// overwriting anything the caller's payload tried to set.
|
|
224
|
+
const createdColumn = resolveTimestampColumn(ctx.type, 'created')
|
|
225
|
+
if (createdColumn) {
|
|
226
|
+
row[createdColumn] = new Date().toISOString()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const { data, error } = await client
|
|
230
|
+
.from(ctx.type.source)
|
|
231
|
+
.insert(row)
|
|
232
|
+
.select('*')
|
|
233
|
+
.single()
|
|
234
|
+
|
|
235
|
+
if (error) {
|
|
236
|
+
throw toContentError(error)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return mapColumnsToFields(ctx.type, data)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function update(
|
|
243
|
+
ctx: PlutoContentContext,
|
|
244
|
+
id: string | number,
|
|
245
|
+
values: Record<string, unknown>
|
|
246
|
+
): Promise<PlutoContentItem> {
|
|
247
|
+
const client = await getClient(ctx.event)
|
|
248
|
+
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
249
|
+
const row = mapFieldsToColumns(ctx.type, values)
|
|
250
|
+
|
|
251
|
+
// See the matching comment in create(): status is not a declared field,
|
|
252
|
+
// so mapFieldsToColumns never sees it. Read it from the fixed
|
|
253
|
+
// conceptual key `status` in the raw payload.
|
|
254
|
+
const statusColumn = resolveStatusColumn(ctx.type)
|
|
255
|
+
if (statusColumn && Object.hasOwn(values, 'status')) {
|
|
256
|
+
row[statusColumn] = values.status
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// A client never controls its own update timestamp. Always stamp it,
|
|
260
|
+
// overwriting anything the caller's payload tried to set.
|
|
261
|
+
const updatedColumn = resolveTimestampColumn(ctx.type, 'updated')
|
|
262
|
+
if (updatedColumn) {
|
|
263
|
+
row[updatedColumn] = new Date().toISOString()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Published-at preservation, generalizing supabase-blog's hand-written
|
|
267
|
+
// edit route: stamp published-at the first time a row is published, but
|
|
268
|
+
// never overwrite an already-set value on a later edit.
|
|
269
|
+
if (
|
|
270
|
+
statusColumn
|
|
271
|
+
&& ctx.type.status
|
|
272
|
+
&& ctx.type.status.publishedAtColumn
|
|
273
|
+
&& row[statusColumn] === ctx.type.status.publishedValue
|
|
274
|
+
) {
|
|
275
|
+
const publishedAtColumn = ctx.type.status.publishedAtColumn
|
|
276
|
+
// Selects the whole row rather than the one dynamic column name:
|
|
277
|
+
// postgrest-js parses a `select()` argument at the type level, and a
|
|
278
|
+
// non-literal (runtime) column name resolves to its own
|
|
279
|
+
// `GenericStringError` type instead of a usable row shape.
|
|
280
|
+
const { data: existing, error: fetchError } = await client
|
|
281
|
+
.from(ctx.type.source)
|
|
282
|
+
.select('*')
|
|
283
|
+
.eq(pkColumn, id)
|
|
284
|
+
.maybeSingle()
|
|
285
|
+
|
|
286
|
+
if (fetchError) {
|
|
287
|
+
throw toContentError(fetchError)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (existing && (existing as Record<string, unknown>)[publishedAtColumn] == null) {
|
|
291
|
+
row[publishedAtColumn] = new Date().toISOString()
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const { data, error } = await client
|
|
296
|
+
.from(ctx.type.source)
|
|
297
|
+
.update(row)
|
|
298
|
+
.eq(pkColumn, id)
|
|
299
|
+
.select('*')
|
|
300
|
+
.single()
|
|
301
|
+
|
|
302
|
+
if (error) {
|
|
303
|
+
throw toContentError(error)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return mapColumnsToFields(ctx.type, data)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function remove(ctx: PlutoContentContext, id: string | number): Promise<void> {
|
|
310
|
+
const client = await getClient(ctx.event)
|
|
311
|
+
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
312
|
+
|
|
313
|
+
const { error } = await client.from(ctx.type.source).delete().eq(pkColumn, id)
|
|
314
|
+
|
|
315
|
+
if (error) {
|
|
316
|
+
throw toContentError(error)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Delegates straight to `requireCapability`, unchanged. */
|
|
321
|
+
async function authorize(event: H3Event, capability: string): Promise<void> {
|
|
322
|
+
await requireCapability(event, capability)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Builds the Supabase/PostgREST-backed `PlutoContentAdapter`. Register it with `registerContentAdapter` from a `server/plugins/*.ts` file. */
|
|
326
|
+
export function createSupabaseContentAdapter(): PlutoContentAdapter {
|
|
327
|
+
return {
|
|
328
|
+
id: 'supabase',
|
|
329
|
+
list,
|
|
330
|
+
get,
|
|
331
|
+
create,
|
|
332
|
+
update,
|
|
333
|
+
remove,
|
|
334
|
+
authorize,
|
|
335
|
+
}
|
|
336
|
+
}
|
|
Binary file
|
|
Binary file
|