@plutocms/supabase 0.22.0 → 0.24.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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.24.0](https://github.com/plutocms/supabase/compare/v0.23.0...v0.24.0) (2026-11-06)
4
+
5
+
6
+ ### Features
7
+
8
+ * **supabase-blog:** tags and categories for posts ([#225](https://github.com/plutocms/supabase/issues/225)) ([36263de](https://github.com/plutocms/supabase/commit/36263def50c4f1e1a4226be5cd14b270d27bb097))
9
+
10
+ ## [0.23.0](https://github.com/plutocms/supabase/compare/v0.22.0...v0.23.0) (2026-11-03)
11
+
12
+
13
+ ### Features
14
+
15
+ * keep the revisions of every content item, for the History of core (migration 011) ([#198](https://github.com/plutocms/supabase/issues/198)) ([9c1f3f4](https://github.com/plutocms/supabase/commit/9c1f3f43569afba62a959668d600afb09c82f855))
16
+
3
17
  ## [0.22.0](https://github.com/plutocms/supabase/compare/v0.21.0...v0.22.0) (2026-11-02)
4
18
 
5
19
 
package/FEATURES.md CHANGED
@@ -16,7 +16,8 @@ policies shared by every project here.
16
16
  @.claude/skills/users-and-roles/SKILL.md
17
17
  - Content adapter: a Supabase/PostgREST-backed `PlutoContentAdapter`, plugging any layer's
18
18
  declared content type into `@plutocms/core`'s generic content routes. It supports query
19
- filters, puts the timestamps on items, and reads the public API of core with an anonymous client.
19
+ filters (a filter on a `multiple` reference matches a list column) and `ids`, puts the timestamps on items, and reads the public API of core with an anonymous client.
20
+ It keeps the revisions of every item (`pluto_content_revisions`, migration 011) for the History of core.
20
21
  @.claude/skills/content-adapter/SKILL.md
21
22
  - Auth driver: plugs Supabase sign-in into the admin shell that pluto core owns (navbar, sign-in
22
23
  page, route guard), keeps the setup guard in the driver, and registers the backend badge
@@ -0,0 +1,60 @@
1
+ -- The saved versions of content items, for History in the edit form (the content-model skill of
2
+ -- @plutocms/core, "Revisions"). The content adapter of this layer writes one row after each create
3
+ -- and update, with the client of the signed-in user. Every content type of every layer shares this
4
+ -- table. Every statement is idempotent.
5
+
6
+ create table if not exists public.pluto_content_revisions (
7
+ id bigint generated by default as identity not null,
8
+ created_at timestamp with time zone not null default now(),
9
+ type text not null, -- the content type name
10
+ item_id text not null, -- the primary key of the item, as text
11
+ action text null, -- create, update, publish or unpublish
12
+ data jsonb not null, -- the item after the save, by field name
13
+ author_id uuid null default auth.uid() references auth.users (id) on delete set null,
14
+ author_name text null, -- the name at save time
15
+ capability text null, -- the write capability of the type at save time
16
+ constraint pluto_content_revisions_pkey primary key (id),
17
+ constraint pluto_content_revisions_action_check check (
18
+ action is null or action in ('create', 'update', 'publish', 'unpublish', 'rename')
19
+ )
20
+ );
21
+
22
+ create index if not exists pluto_content_revisions_item_idx
23
+ on public.pluto_content_revisions (type, item_id, id desc);
24
+
25
+ alter table public.pluto_content_revisions enable row level security;
26
+
27
+ -- An editor of the type: the stored write capability, or any staff capability for a type with
28
+ -- none. public.has_capability() folds in public.is_admin().
29
+ drop policy if exists "pluto_content_revisions_select" on public.pluto_content_revisions;
30
+ create policy "pluto_content_revisions_select"
31
+ on public.pluto_content_revisions for select
32
+ to authenticated
33
+ using (
34
+ (capability is null and exists (select 1 from public.my_capabilities()))
35
+ or public.has_capability(capability)
36
+ );
37
+
38
+ drop policy if exists "pluto_content_revisions_insert" on public.pluto_content_revisions;
39
+ create policy "pluto_content_revisions_insert"
40
+ on public.pluto_content_revisions for insert
41
+ to authenticated
42
+ with check (
43
+ author_id = auth.uid()
44
+ and (
45
+ (capability is null and exists (select 1 from public.my_capabilities()))
46
+ or public.has_capability(capability)
47
+ )
48
+ );
49
+
50
+ drop policy if exists "pluto_content_revisions_delete" on public.pluto_content_revisions;
51
+ create policy "pluto_content_revisions_delete"
52
+ on public.pluto_content_revisions for delete
53
+ to authenticated
54
+ using (
55
+ (capability is null and exists (select 1 from public.my_capabilities()))
56
+ or public.has_capability(capability)
57
+ );
58
+
59
+ grant select, insert, delete on public.pluto_content_revisions to authenticated;
60
+ grant select, insert, update, delete on public.pluto_content_revisions to service_role;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/supabase",
3
3
  "type": "module",
4
- "version": "0.22.0",
4
+ "version": "0.24.0",
5
5
  "description": "Supabase backend layer for Pluto",
6
6
  "author": "Victor Ribeiro",
7
7
  "homepage": "https://github.com/plutocms/supabase/tree/main/packages/supabase#readme",
@@ -30,7 +30,7 @@
30
30
  "dependencies": {
31
31
  "@nuxt/ui": "^4.12.0",
32
32
  "@nuxtjs/supabase": "^2.1.0",
33
- "@plutocms/core": ">=0.48.0 <1.0.0",
33
+ "@plutocms/core": ">=0.58.0 <1.0.0",
34
34
  "@supabase/supabase-js": "^2.117.3",
35
35
  "postgres": "^3.4.9",
36
36
  "supabase": "^2.121.0",
@@ -3,6 +3,13 @@ import type { H3Event } from 'h3'
3
3
  import { createClient } from '@supabase/supabase-js'
4
4
  import { serverSupabaseClient } from '#supabase/server'
5
5
  import { requireCapability } from './capability-guard'
6
+ import {
7
+ deleteRevisions,
8
+ getItemRevision,
9
+ listItemRevisions,
10
+ recordRevision,
11
+ updateAction,
12
+ } from './content-revisions'
6
13
 
7
14
  /**
8
15
  * A Supabase client for one request, deliberately untyped.
@@ -246,15 +253,31 @@ function resolveFieldColumn(
246
253
  return fieldName
247
254
  }
248
255
 
256
+ /** Whether a field stores a list in one column: a `multiple` reference. */
257
+ function isListField(type: PlutoContentType, fieldName: string): boolean {
258
+ const field = type.fields.find((candidate) => candidate.name === fieldName)
259
+
260
+ return field?.type === 'reference' && field.multiple === true
261
+ }
262
+
249
263
  async function list(
250
264
  ctx: PlutoContentContext,
251
265
  query: PlutoContentQuery
252
266
  ): Promise<PlutoContentListResult> {
267
+ // An empty id list matches no item: no query.
268
+ if (query.ids && query.ids.length === 0) {
269
+ return { data: [], total: 0 }
270
+ }
271
+
253
272
  const client = await getClient(ctx.event)
254
273
  const pkColumn = ctx.type.primaryKey ?? 'id'
255
274
 
256
275
  let builder = client.from(ctx.type.source).select('*', { count: 'exact' })
257
276
 
277
+ if (query.ids) {
278
+ builder = builder.in(pkColumn, query.ids)
279
+ }
280
+
258
281
  const statusColumn = resolveStatusColumn(ctx.type)
259
282
  if (statusColumn && query.includeUnpublished !== true) {
260
283
  const publishedValue =
@@ -263,7 +286,12 @@ async function list(
263
286
  }
264
287
 
265
288
  for (const [field, value] of Object.entries(query.filters ?? {})) {
266
- builder = builder.eq(resolveFieldColumn(ctx.type, field, 'filter'), value)
289
+ const column = resolveFieldColumn(ctx.type, field, 'filter')
290
+
291
+ // A list column (a `multiple` reference) matches when it holds the value.
292
+ builder = isListField(ctx.type, field)
293
+ ? builder.contains(column, [value])
294
+ : builder.eq(column, value)
267
295
  }
268
296
 
269
297
  if (query.search) {
@@ -408,7 +436,9 @@ async function create(
408
436
  throw toContentError(error)
409
437
  }
410
438
 
411
- return toItem(ctx.type, data)
439
+ const item = toItem(ctx.type, data)
440
+ await recordRevision(ctx, client, item, 'create')
441
+ return item
412
442
  }
413
443
 
414
444
  async function update(
@@ -435,21 +465,15 @@ async function update(
435
465
  row[updatedColumn] = new Date().toISOString()
436
466
  }
437
467
 
438
- // Published-at preservation, generalizing supabase-blog's hand-written
439
- // edit route: stamp published-at the first time a row is published, but
440
- // never overwrite an already-set value on a later edit.
441
- if (
442
- statusColumn &&
443
- ctx.type.status &&
444
- ctx.type.status.publishedAtColumn &&
445
- row[statusColumn] === ctx.type.status.publishedValue
446
- ) {
447
- const publishedAtColumn = ctx.type.status.publishedAtColumn
448
- // Selects the whole row rather than the one dynamic column name:
449
- // postgrest-js parses a `select()` argument at the type level, and a
450
- // non-literal (runtime) column name resolves to its own
451
- // `GenericStringError` type instead of a usable row shape.
452
- const { data: existing, error: fetchError } = await client
468
+ // A payload with a status reads the stored row first: the published-at
469
+ // rule and the revision action need the old status. Selects the whole row
470
+ // rather than the one dynamic column name: postgrest-js parses a
471
+ // `select()` argument at the type level, and a non-literal (runtime)
472
+ // column name resolves to its own `GenericStringError` type instead of a
473
+ // usable row shape.
474
+ let existing: Record<string, unknown> | null = null
475
+ if (statusColumn && Object.hasOwn(values, 'status')) {
476
+ const { data: stored, error: fetchError } = await client
453
477
  .from(ctx.type.source)
454
478
  .select('*')
455
479
  .eq(pkColumn, id)
@@ -459,12 +483,21 @@ async function update(
459
483
  throw toContentError(fetchError)
460
484
  }
461
485
 
462
- if (
463
- existing &&
464
- (existing as Record<string, unknown>)[publishedAtColumn] == null
465
- ) {
466
- row[publishedAtColumn] = new Date().toISOString()
467
- }
486
+ existing = stored as Record<string, unknown> | null
487
+ }
488
+
489
+ // Published-at preservation, generalizing supabase-blog's hand-written
490
+ // edit route: stamp published-at the first time a row is published, but
491
+ // never overwrite an already-set value on a later edit.
492
+ if (
493
+ statusColumn &&
494
+ ctx.type.status &&
495
+ ctx.type.status.publishedAtColumn &&
496
+ row[statusColumn] === ctx.type.status.publishedValue &&
497
+ existing &&
498
+ existing[ctx.type.status.publishedAtColumn] == null
499
+ ) {
500
+ row[ctx.type.status.publishedAtColumn] = new Date().toISOString()
468
501
  }
469
502
 
470
503
  const { data, error } = await client
@@ -478,7 +511,20 @@ async function update(
478
511
  throw toContentError(error)
479
512
  }
480
513
 
481
- return toItem(ctx.type, data)
514
+ const item = toItem(ctx.type, data)
515
+ await recordRevision(
516
+ ctx,
517
+ client,
518
+ item,
519
+ updateAction(
520
+ ctx.type,
521
+ existing && statusColumn ? existing[statusColumn] : undefined,
522
+ statusColumn && Object.hasOwn(values, 'status')
523
+ ? values.status
524
+ : undefined
525
+ )
526
+ )
527
+ return item
482
528
  }
483
529
 
484
530
  async function remove(
@@ -493,6 +539,36 @@ async function remove(
493
539
  if (error) {
494
540
  throw toContentError(error)
495
541
  }
542
+
543
+ await deleteRevisions(ctx, client, id)
544
+ }
545
+
546
+ /** The saved versions of one item, newest first. See `content-revisions.ts`. */
547
+ async function listRevisions(
548
+ ctx: PlutoContentContext,
549
+ id: string | number,
550
+ query: { limit: number; cursor?: string }
551
+ ): Promise<PlutoContentRevisionPage> {
552
+ const client = await getClient(ctx.event)
553
+ try {
554
+ return await listItemRevisions(ctx, client, id, query)
555
+ } catch (error) {
556
+ throw toContentError(error as PostgrestError)
557
+ }
558
+ }
559
+
560
+ /** One item as it was at a revision. See `content-revisions.ts`. */
561
+ async function getRevision(
562
+ ctx: PlutoContentContext,
563
+ id: string | number,
564
+ revisionId: string
565
+ ): Promise<PlutoContentItem | null> {
566
+ const client = await getClient(ctx.event)
567
+ try {
568
+ return await getItemRevision(ctx, client, id, revisionId)
569
+ } catch (error) {
570
+ throw toContentError(error as PostgrestError)
571
+ }
496
572
  }
497
573
 
498
574
  /**
@@ -541,5 +617,7 @@ export function createSupabaseContentAdapter(): PlutoContentAdapter {
541
617
  authorize,
542
618
  countCreated,
543
619
  timestampKeys,
620
+ listRevisions,
621
+ getRevision,
544
622
  }
545
623
  }
@@ -0,0 +1,219 @@
1
+ import type { SupabaseClient } from '@supabase/supabase-js'
2
+ import type { H3Event } from 'h3'
3
+ import { serverSupabaseUser } from '#supabase/server'
4
+
5
+ /**
6
+ * The saved versions of content items (011_content_revisions.sql). The content adapter writes one
7
+ * row after each create and update, with the client of the signed-in user, and core's History reads
8
+ * them through `listRevisions` and `getRevision`. See the content-adapter skill ("Revisions").
9
+ */
10
+
11
+ export const REVISIONS_TABLE = 'pluto_content_revisions'
12
+
13
+ /** The most revisions kept for one item. Older ones are deleted after an insert. */
14
+ export const MAX_REVISIONS_PER_ITEM = 100
15
+
16
+ type RevisionAction = 'create' | 'update' | 'publish' | 'unpublish'
17
+
18
+ /**
19
+ * The action of an update: `publish` or `unpublish` when the status crosses the published value,
20
+ * else `update`. `before` is the stored status, `after` the status of the payload (undefined when
21
+ * the payload has none).
22
+ */
23
+ export function updateAction(
24
+ type: PlutoContentType,
25
+ before: unknown,
26
+ after: unknown
27
+ ): RevisionAction {
28
+ if (!type.status || after === undefined) {
29
+ return 'update'
30
+ }
31
+ const published = type.status.publishedValue || 'published'
32
+ if (after === published && before !== published) {
33
+ return 'publish'
34
+ }
35
+ if (after !== published && before === published) {
36
+ return 'unpublish'
37
+ }
38
+ return 'update'
39
+ }
40
+
41
+ /** The name of the signed-in user for a revision: the profile name, else the email. */
42
+ async function authorOf(
43
+ event: H3Event,
44
+ client: SupabaseClient
45
+ ): Promise<{
46
+ id: string
47
+ name: string | null
48
+ } | null> {
49
+ const user = (await serverSupabaseUser(event)) as {
50
+ sub?: string
51
+ id?: string
52
+ email?: string
53
+ } | null
54
+ const id = user?.sub ?? user?.id
55
+ if (!id) {
56
+ return null
57
+ }
58
+ const { data } = await client
59
+ .from('profiles')
60
+ .select('display_name, username, email')
61
+ .eq('id', id)
62
+ .maybeSingle()
63
+ const profile = data as {
64
+ display_name?: string | null
65
+ username?: string | null
66
+ email?: string | null
67
+ } | null
68
+ return {
69
+ id,
70
+ name:
71
+ profile?.display_name ||
72
+ profile?.username ||
73
+ profile?.email ||
74
+ user?.email ||
75
+ null,
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Writes one revision of `item` after a save, then deletes the revisions above
81
+ * `MAX_REVISIONS_PER_ITEM`. It never throws: a failed write (011 not applied yet, a policy) only
82
+ * logs a warning, because the save itself already worked.
83
+ */
84
+ export async function recordRevision(
85
+ ctx: PlutoContentContext,
86
+ client: SupabaseClient,
87
+ item: PlutoContentItem,
88
+ action: RevisionAction
89
+ ): Promise<void> {
90
+ try {
91
+ const author = await authorOf(ctx.event, client)
92
+ if (!author) {
93
+ return
94
+ }
95
+ const itemId = String(item.id)
96
+ const { error } = await client.from(REVISIONS_TABLE).insert({
97
+ type: ctx.type.name,
98
+ item_id: itemId,
99
+ action,
100
+ data: item,
101
+ author_id: author.id,
102
+ author_name: author.name,
103
+ capability: ctx.type.capabilities?.write ?? null,
104
+ })
105
+ if (error) {
106
+ throw error
107
+ }
108
+
109
+ const { data: old, error: oldError } = await client
110
+ .from(REVISIONS_TABLE)
111
+ .select('id')
112
+ .eq('type', ctx.type.name)
113
+ .eq('item_id', itemId)
114
+ .order('id', { ascending: false })
115
+ .range(MAX_REVISIONS_PER_ITEM, MAX_REVISIONS_PER_ITEM + 99)
116
+ if (oldError) {
117
+ throw oldError
118
+ }
119
+ const ids = (old ?? []).map((row: { id: number }) => row.id)
120
+ if (ids.length > 0) {
121
+ await client.from(REVISIONS_TABLE).delete().in('id', ids)
122
+ }
123
+ } catch (error) {
124
+ console.warn(
125
+ `[content-adapter] Could not save a revision of ${ctx.type.name} "${String(item.id)}": ${(error as { message?: string }).message ?? String(error)}`
126
+ )
127
+ }
128
+ }
129
+
130
+ /** Deletes the revisions of a deleted item. Best effort, like `recordRevision`. */
131
+ export async function deleteRevisions(
132
+ ctx: PlutoContentContext,
133
+ client: SupabaseClient,
134
+ id: string | number
135
+ ): Promise<void> {
136
+ const { error } = await client
137
+ .from(REVISIONS_TABLE)
138
+ .delete()
139
+ .eq('type', ctx.type.name)
140
+ .eq('item_id', String(id))
141
+ if (error) {
142
+ console.warn(
143
+ `[content-adapter] Could not delete the revisions of ${ctx.type.name} "${String(id)}": ${error.message}`
144
+ )
145
+ }
146
+ }
147
+
148
+ interface RevisionRow {
149
+ id: number
150
+ created_at: string
151
+ action: RevisionAction | null
152
+ author_name: string | null
153
+ }
154
+
155
+ /**
156
+ * The revisions of one item, newest first. The cursor is the id of the last revision of the page
157
+ * before (keyset paging). Row Level Security limits the rows to the editors of the type.
158
+ */
159
+ export async function listItemRevisions(
160
+ ctx: PlutoContentContext,
161
+ client: SupabaseClient,
162
+ id: string | number,
163
+ query: {
164
+ limit: number
165
+ cursor?: string
166
+ }
167
+ ): Promise<PlutoContentRevisionPage> {
168
+ let builder = client
169
+ .from(REVISIONS_TABLE)
170
+ .select('id, created_at, action, author_name')
171
+ .eq('type', ctx.type.name)
172
+ .eq('item_id', String(id))
173
+ .order('id', { ascending: false })
174
+ .limit(query.limit + 1)
175
+ if (query.cursor && /^\d+$/.test(query.cursor)) {
176
+ builder = builder.lt('id', Number(query.cursor))
177
+ }
178
+ const { data, error } = await builder
179
+ if (error) {
180
+ throw error
181
+ }
182
+ const rows = (data ?? []) as RevisionRow[]
183
+ const page = rows.slice(0, query.limit)
184
+ return {
185
+ data: page.map((row) => ({
186
+ id: String(row.id),
187
+ createdAt: row.created_at,
188
+ ...(row.author_name && { author: { name: row.author_name } }),
189
+ ...(row.action && { action: row.action }),
190
+ })),
191
+ ...(rows.length > query.limit && {
192
+ nextCursor: String(page.at(-1)!.id),
193
+ }),
194
+ }
195
+ }
196
+
197
+ /** The item as it was at one revision, or `null`. */
198
+ export async function getItemRevision(
199
+ ctx: PlutoContentContext,
200
+ client: SupabaseClient,
201
+ id: string | number,
202
+ revisionId: string
203
+ ): Promise<PlutoContentItem | null> {
204
+ if (!/^\d+$/.test(revisionId)) {
205
+ return null
206
+ }
207
+ const { data, error } = await client
208
+ .from(REVISIONS_TABLE)
209
+ .select('data')
210
+ .eq('id', Number(revisionId))
211
+ .eq('type', ctx.type.name)
212
+ .eq('item_id', String(id))
213
+ .maybeSingle()
214
+ if (error) {
215
+ throw error
216
+ }
217
+ const stored = (data as { data?: PlutoContentItem } | null)?.data
218
+ return stored ? { ...stored, id: stored.id ?? id } : null
219
+ }