@plutocms/supabase 0.7.2 → 0.8.1

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.1](https://github.com/plutocms/supabase/compare/v0.8.0...v0.8.1) (2026-09-13)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **content:** widen the adapter's client cast through unknown ([#62](https://github.com/plutocms/supabase/issues/62)) ([f539958](https://github.com/plutocms/supabase/commit/f539958ac062ebd3d295731e419801d5ade6861c))
9
+
10
+ ## [0.8.0](https://github.com/plutocms/supabase/compare/v0.7.2...v0.8.0) (2026-09-13)
11
+
12
+
13
+ ### Features
14
+
15
+ * **content:** add Supabase-backed PlutoContentAdapter (wave 4) ([#60](https://github.com/plutocms/supabase/issues/60)) ([834136d](https://github.com/plutocms/supabase/commit/834136d39f2297ad771cf00de5bc33f0e811a4b9))
16
+
3
17
  ## [0.7.2](https://github.com/plutocms/supabase/compare/v0.7.1...v0.7.2) (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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/supabase",
3
3
  "type": "module",
4
- "version": "0.7.2",
4
+ "version": "0.8.1",
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.5.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"
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Registers this layer's Supabase/PostgREST-backed `PlutoContentAdapter`
3
+ * with core's generic content routes, at Nitro startup.
4
+ */
5
+ export default defineNitroPlugin(() => {
6
+ registerContentAdapter(createSupabaseContentAdapter())
7
+ })
@@ -0,0 +1,350 @@
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
+ * Goes through `unknown` first: `serverSupabaseClient`'s inferred
20
+ * `Database` type is whichever layer's own generated types are in scope
21
+ * at the *call site* — this repo's own `shared/types/supabase.ts` when
22
+ * typechecked standalone, but a different consumer's own generated types
23
+ * when this file is typechecked as part of a deeper extends chain (a
24
+ * layer that extends this one, extending it in turn). A direct `as
25
+ * SupabaseClient` cast is only valid when the two concrete
26
+ * instantiations "sufficiently overlap"; a client typed against some
27
+ * other, unrelated `Database` shape does not, and TypeScript correctly
28
+ * refuses the direct assertion in that case. Routing through `unknown`
29
+ * is the standard, correct way to widen past that check on purpose —
30
+ * this cast has always been meant to discard the specific `Database`
31
+ * type, not to assert against one.
32
+ */
33
+ async function getClient(event: H3Event): Promise<SupabaseClient> {
34
+ return (await serverSupabaseClient(event)) as unknown as SupabaseClient
35
+ }
36
+
37
+ /**
38
+ * Resolves the storage column for a content type's created/updated
39
+ * timestamp. Returns `undefined` when timestamps are disabled entirely
40
+ * (`type.timestamps === false`) or when this one timestamp is disabled
41
+ * (`type.timestamps[key] === false`).
42
+ */
43
+ export function resolveTimestampColumn(
44
+ type: PlutoContentType,
45
+ key: 'created' | 'updated'
46
+ ): string | undefined {
47
+ if (type.timestamps === false) {
48
+ return undefined
49
+ }
50
+
51
+ const configured = type.timestamps?.[key]
52
+
53
+ if (configured === false) {
54
+ return undefined
55
+ }
56
+
57
+ return configured ?? (key === 'created' ? 'created_at' : 'updated_at')
58
+ }
59
+
60
+ /**
61
+ * Resolves the storage column for a content type's status field. Returns
62
+ * `undefined` when status is disabled entirely (`type.status === false`
63
+ * or unset).
64
+ */
65
+ export function resolveStatusColumn(type: PlutoContentType): string | undefined {
66
+ if (!type.status) {
67
+ return undefined
68
+ }
69
+
70
+ return type.status.column ?? 'status'
71
+ }
72
+
73
+ /**
74
+ * Maps a raw PostgREST error to an H3/Nuxt error safe to send to a
75
+ * client. Logs the raw error first, so the real message always reaches
76
+ * server logs even when the client-facing message is generic. No return
77
+ * type annotation: `createError`'s own return type (`NuxtError`) is left
78
+ * to flow through, rather than restated here.
79
+ */
80
+ export function toContentError(error: PostgrestError) {
81
+ console.error('[content-adapter] Postgres error:', error)
82
+
83
+ // 23505: unique_violation.
84
+ if (error.code === '23505') {
85
+ return createError({
86
+ statusCode: 409,
87
+ statusMessage: 'A record with this value already exists.',
88
+ })
89
+ }
90
+
91
+ // 23503: foreign_key_violation.
92
+ if (error.code === '23503') {
93
+ return createError({
94
+ statusCode: 400,
95
+ statusMessage: 'This references a record that does not exist.',
96
+ })
97
+ }
98
+
99
+ return createError({ statusCode: 500, statusMessage: error.message })
100
+ }
101
+
102
+ /** 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. */
103
+ function resolveFieldColumn(type: PlutoContentType, fieldName: string, context: string): string {
104
+ const field = type.fields.find((candidate) => candidate.name === fieldName)
105
+
106
+ if (field) {
107
+ return fieldColumn(field)
108
+ }
109
+
110
+ console.warn(
111
+ `[content-adapter] Content type "${type.name}" has no field named "${fieldName}" (${context}). Falling back to the raw name as the column.`
112
+ )
113
+
114
+ return fieldName
115
+ }
116
+
117
+ async function list(ctx: PlutoContentContext, query: PlutoContentQuery): Promise<PlutoContentListResult> {
118
+ const client = await getClient(ctx.event)
119
+ const pkColumn = ctx.type.primaryKey ?? 'id'
120
+
121
+ let builder = client.from(ctx.type.source).select('*', { count: 'exact' })
122
+
123
+ const statusColumn = resolveStatusColumn(ctx.type)
124
+ if (statusColumn && query.includeUnpublished !== true) {
125
+ const publishedValue = (ctx.type.status && ctx.type.status.publishedValue) || 'published'
126
+ builder = builder.eq(statusColumn, publishedValue)
127
+ }
128
+
129
+ if (query.search) {
130
+ const titleColumn = resolveFieldColumn(ctx.type, ctx.type.titleField, 'titleField')
131
+ builder = builder.ilike(titleColumn, `%${query.search}%`)
132
+ }
133
+
134
+ const sort = query.sort ?? ctx.type.defaultSort
135
+ if (sort) {
136
+ const sortColumn = resolveFieldColumn(ctx.type, sort.field, 'sort')
137
+ builder = builder.order(sortColumn, { ascending: sort.direction !== 'desc' })
138
+ } else {
139
+ const createdColumn = resolveTimestampColumn(ctx.type, 'created')
140
+ builder = builder.order(createdColumn ?? pkColumn, { ascending: false })
141
+ }
142
+
143
+ if (query.offset !== undefined) {
144
+ const limit = query.limit ?? 50
145
+ builder = builder.range(query.offset, query.offset + limit - 1)
146
+ } else if (query.limit !== undefined) {
147
+ builder = builder.limit(query.limit)
148
+ }
149
+
150
+ const { data, error, count } = await builder
151
+
152
+ if (error) {
153
+ throw toContentError(error)
154
+ }
155
+
156
+ return {
157
+ data: (data ?? []).map((row: Record<string, unknown>) => mapColumnsToFields(ctx.type, row)),
158
+ total: count ?? undefined,
159
+ }
160
+ }
161
+
162
+ /**
163
+ * 22P02 (invalid_text_representation): the value doesn't parse as the
164
+ * primary key column's own SQL type — for example a slug like
165
+ * "hello-world" against a `bigint` id column. This is not a real error:
166
+ * it means "not found by id, at the database level", the same outcome as
167
+ * a clean empty result, and `get()` must fall through to trying the slug
168
+ * column exactly as it would for an empty result. Postgres reports a type
169
+ * mismatch as an error rather than zero rows, so this has to be handled
170
+ * separately from the empty-result case, not folded into it.
171
+ */
172
+ function isInvalidIdShape(error: PostgrestError): boolean {
173
+ return error.code === '22P02'
174
+ }
175
+
176
+ async function get(ctx: PlutoContentContext, idOrSlug: string | number): Promise<PlutoContentItem | null> {
177
+ const client = await getClient(ctx.event)
178
+ const pkColumn = ctx.type.primaryKey ?? 'id'
179
+
180
+ const { data, error } = await client
181
+ .from(ctx.type.source)
182
+ .select('*')
183
+ .eq(pkColumn, idOrSlug)
184
+ .maybeSingle()
185
+
186
+ if (error && !isInvalidIdShape(error)) {
187
+ throw toContentError(error)
188
+ }
189
+
190
+ if (data) {
191
+ return mapColumnsToFields(ctx.type, data)
192
+ }
193
+
194
+ if (ctx.type.slug) {
195
+ const slugColumn = resolveFieldColumn(ctx.type, ctx.type.slug.field, 'slug.field')
196
+
197
+ const bySlug = await client
198
+ .from(ctx.type.source)
199
+ .select('*')
200
+ .eq(slugColumn, idOrSlug)
201
+ .maybeSingle()
202
+
203
+ if (bySlug.error) {
204
+ throw toContentError(bySlug.error)
205
+ }
206
+
207
+ if (bySlug.data) {
208
+ return mapColumnsToFields(ctx.type, bySlug.data)
209
+ }
210
+ }
211
+
212
+ return null
213
+ }
214
+
215
+ async function create(ctx: PlutoContentContext, values: Record<string, unknown>): Promise<PlutoContentItem> {
216
+ const client = await getClient(ctx.event)
217
+ const row = mapFieldsToColumns(ctx.type, values)
218
+
219
+ // Status is not a declared field (see PlutoContentType.status — a
220
+ // separate, top-level concern from `fields`, unlike `slug`, which
221
+ // references a field also present in `fields`), so mapFieldsToColumns
222
+ // never sees it — it filters strictly to declared field names. A
223
+ // payload's status lives at the fixed conceptual key `status`,
224
+ // regardless of what `type.status.column` names in storage, mirroring
225
+ // how a field's own name (not its storage column) is the payload key
226
+ // everywhere else in this contract.
227
+ const statusColumn = resolveStatusColumn(ctx.type)
228
+ if (statusColumn) {
229
+ if (Object.hasOwn(values, 'status')) {
230
+ row[statusColumn] = values.status
231
+ } else if (ctx.type.status && ctx.type.status.default !== undefined) {
232
+ row[statusColumn] = ctx.type.status.default
233
+ }
234
+ }
235
+
236
+ // A client never controls its own creation timestamp. Always stamp it,
237
+ // overwriting anything the caller's payload tried to set.
238
+ const createdColumn = resolveTimestampColumn(ctx.type, 'created')
239
+ if (createdColumn) {
240
+ row[createdColumn] = new Date().toISOString()
241
+ }
242
+
243
+ const { data, error } = await client
244
+ .from(ctx.type.source)
245
+ .insert(row)
246
+ .select('*')
247
+ .single()
248
+
249
+ if (error) {
250
+ throw toContentError(error)
251
+ }
252
+
253
+ return mapColumnsToFields(ctx.type, data)
254
+ }
255
+
256
+ async function update(
257
+ ctx: PlutoContentContext,
258
+ id: string | number,
259
+ values: Record<string, unknown>
260
+ ): Promise<PlutoContentItem> {
261
+ const client = await getClient(ctx.event)
262
+ const pkColumn = ctx.type.primaryKey ?? 'id'
263
+ const row = mapFieldsToColumns(ctx.type, values)
264
+
265
+ // See the matching comment in create(): status is not a declared field,
266
+ // so mapFieldsToColumns never sees it. Read it from the fixed
267
+ // conceptual key `status` in the raw payload.
268
+ const statusColumn = resolveStatusColumn(ctx.type)
269
+ if (statusColumn && Object.hasOwn(values, 'status')) {
270
+ row[statusColumn] = values.status
271
+ }
272
+
273
+ // A client never controls its own update timestamp. Always stamp it,
274
+ // overwriting anything the caller's payload tried to set.
275
+ const updatedColumn = resolveTimestampColumn(ctx.type, 'updated')
276
+ if (updatedColumn) {
277
+ row[updatedColumn] = new Date().toISOString()
278
+ }
279
+
280
+ // Published-at preservation, generalizing supabase-blog's hand-written
281
+ // edit route: stamp published-at the first time a row is published, but
282
+ // never overwrite an already-set value on a later edit.
283
+ if (
284
+ statusColumn
285
+ && ctx.type.status
286
+ && ctx.type.status.publishedAtColumn
287
+ && row[statusColumn] === ctx.type.status.publishedValue
288
+ ) {
289
+ const publishedAtColumn = ctx.type.status.publishedAtColumn
290
+ // Selects the whole row rather than the one dynamic column name:
291
+ // postgrest-js parses a `select()` argument at the type level, and a
292
+ // non-literal (runtime) column name resolves to its own
293
+ // `GenericStringError` type instead of a usable row shape.
294
+ const { data: existing, error: fetchError } = await client
295
+ .from(ctx.type.source)
296
+ .select('*')
297
+ .eq(pkColumn, id)
298
+ .maybeSingle()
299
+
300
+ if (fetchError) {
301
+ throw toContentError(fetchError)
302
+ }
303
+
304
+ if (existing && (existing as Record<string, unknown>)[publishedAtColumn] == null) {
305
+ row[publishedAtColumn] = new Date().toISOString()
306
+ }
307
+ }
308
+
309
+ const { data, error } = await client
310
+ .from(ctx.type.source)
311
+ .update(row)
312
+ .eq(pkColumn, id)
313
+ .select('*')
314
+ .single()
315
+
316
+ if (error) {
317
+ throw toContentError(error)
318
+ }
319
+
320
+ return mapColumnsToFields(ctx.type, data)
321
+ }
322
+
323
+ async function remove(ctx: PlutoContentContext, id: string | number): Promise<void> {
324
+ const client = await getClient(ctx.event)
325
+ const pkColumn = ctx.type.primaryKey ?? 'id'
326
+
327
+ const { error } = await client.from(ctx.type.source).delete().eq(pkColumn, id)
328
+
329
+ if (error) {
330
+ throw toContentError(error)
331
+ }
332
+ }
333
+
334
+ /** Delegates straight to `requireCapability`, unchanged. */
335
+ async function authorize(event: H3Event, capability: string): Promise<void> {
336
+ await requireCapability(event, capability)
337
+ }
338
+
339
+ /** Builds the Supabase/PostgREST-backed `PlutoContentAdapter`. Register it with `registerContentAdapter` from a `server/plugins/*.ts` file. */
340
+ export function createSupabaseContentAdapter(): PlutoContentAdapter {
341
+ return {
342
+ id: 'supabase',
343
+ list,
344
+ get,
345
+ create,
346
+ update,
347
+ remove,
348
+ authorize,
349
+ }
350
+ }