@plutocms/supabase 0.21.0 → 0.23.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.23.0](https://github.com/plutocms/supabase/compare/v0.22.0...v0.23.0) (2026-11-03)
4
+
5
+
6
+ ### Features
7
+
8
+ * 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))
9
+
10
+ ## [0.22.0](https://github.com/plutocms/supabase/compare/v0.21.0...v0.22.0) (2026-11-02)
11
+
12
+
13
+ ### Features
14
+
15
+ * the admin navbar shows the Supabase backend, with the project and a dashboard link ([#190](https://github.com/plutocms/supabase/issues/190)) ([2b8de9f](https://github.com/plutocms/supabase/commit/2b8de9f500a71a2afeb65fe97d66be41dac2eed2))
16
+
3
17
  ## [0.21.0](https://github.com/plutocms/supabase/compare/v0.20.0...v0.21.0) (2026-10-31)
4
18
 
5
19
 
package/FEATURES.md CHANGED
@@ -17,9 +17,11 @@ policies shared by every project here.
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
19
  filters, 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
- page, route guard), and keeps the setup guard in the driver.
23
+ page, route guard), keeps the setup guard in the driver, and registers the backend badge
24
+ ("Database · Supabase", with the project and a dashboard link).
23
25
  @.claude/skills/auth-driver/SKILL.md
24
26
  - Profile language: stores each user's admin language in `profiles.locale`, written through a
25
27
  security-definer function, and plugs it into pluto's i18n as a locale driver.
package/README.md CHANGED
@@ -26,7 +26,7 @@ npm i @plutocms/supabase
26
26
  ```ts
27
27
  // nuxt.config.ts
28
28
  export default defineNuxtConfig({
29
- extends: [['@plutocms/supabase', { install: true }]],
29
+ extends: ['@plutocms/supabase'],
30
30
  })
31
31
  ```
32
32
 
@@ -116,7 +116,7 @@ a page of another site cannot call them. A failure is an HTTP error with a trans
116
116
 
117
117
  The public content of the Supabase layers is read through the public API of core,
118
118
  `GET /api/pluto/v1/content/<type>` (and `/<id or slug>`). See
119
- [the public API](https://pluto.vulmix.dev/docs/core/public-api). The routes are anonymous. This layer
119
+ [the public API](https://pluto.vulmix.dev/docs/build/public-api). The routes are anonymous. This layer
120
120
  reads for them with an anon client that has no user session, so Row Level Security applies as for
121
121
  an anonymous visitor, and the answer is the same for everyone. A CDN can cache it. The adapter
122
122
  never uses the service role key for a read. The adapter also supports the `filters` of a public
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ // The popover body of the backend badge of core (`backend.details`): the
3
+ // Supabase project that holds the content, with a link to its dashboard. A
4
+ // local Supabase (or a custom domain) has no project ref, so the host shows.
5
+ import {
6
+ supabaseDashboardLinks,
7
+ supabaseProjectRef,
8
+ } from '../../utils/supabase-project'
9
+
10
+ const { t } = usePlutoI18n()
11
+ const url = String(
12
+ (useRuntimeConfig().public.supabase as { url?: string } | undefined)?.url ??
13
+ ''
14
+ )
15
+ const projectRef = supabaseProjectRef(url)
16
+ const dashboard = projectRef ? supabaseDashboardLinks(projectRef).project : ''
17
+ const host = (() => {
18
+ try {
19
+ return new URL(url).host
20
+ } catch {
21
+ return ''
22
+ }
23
+ })()
24
+ </script>
25
+
26
+ <template>
27
+ <div class="space-y-3">
28
+ <p class="text-muted">{{ t('supabase.backend.description') }}</p>
29
+ <dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
30
+ <dt class="text-muted">
31
+ {{
32
+ projectRef
33
+ ? t('supabase.backend.project')
34
+ : t('supabase.backend.host')
35
+ }}
36
+ </dt>
37
+ <dd class="text-highlighted truncate font-mono text-xs/5">
38
+ {{ projectRef || host }}
39
+ </dd>
40
+ </dl>
41
+ <UButton
42
+ v-if="dashboard"
43
+ :label="t('supabase.backend.openDashboard')"
44
+ :to="dashboard"
45
+ color="neutral"
46
+ icon="i-lucide-external-link"
47
+ size="xs"
48
+ target="_blank"
49
+ variant="outline"
50
+ />
51
+ </div>
52
+ </template>
@@ -11,6 +11,10 @@ const MigrationsBanner = defineAsyncComponent(
11
11
  const SettingsGeneralPanel = defineAsyncComponent(
12
12
  () => import('../components/settings/SettingsGeneralPanel.vue')
13
13
  )
14
+ // The popover of the backend badge of the navbar.
15
+ const SupabaseBackendDetails = defineAsyncComponent(
16
+ () => import('../components/backend/SupabaseBackendDetails.vue')
17
+ )
14
18
  // The guide for the Supabase sign-in emails, a section of the Email tab.
15
19
  const AuthEmailsPanel = defineAsyncComponent(
16
20
  () => import('../components/mail/AuthEmailsPanel.vue')
@@ -152,6 +156,14 @@ export default defineNuxtPlugin(() => {
152
156
  // In development, "View site" opens this dev server, like the old navbar.
153
157
  url: () => (import.meta.dev ? null : siteSettings.value.website_url),
154
158
  },
159
+ // The backend badge of the navbar: the content is in a Supabase database.
160
+ backend: {
161
+ id: 'supabase',
162
+ kind: 'database',
163
+ label: 'Supabase',
164
+ icon: 'i-lucide-database',
165
+ details: SupabaseBackendDetails,
166
+ },
155
167
  // The pending-migrations notice, shown at the top of the admin pages.
156
168
  adminBanners: [{ id: 'migrations', component: MigrationsBanner }],
157
169
  settingsPanels: [
@@ -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;
@@ -386,6 +386,12 @@
386
386
  "noProject": "Pluto could not read the project ref from your Supabase URL. Open the Supabase dashboard by hand.",
387
387
  "rateLimitHint": "After you set custom SMTP, open Authentication, then Rate Limits, and raise the number of emails each hour. The default is low."
388
388
  }
389
+ },
390
+ "backend": {
391
+ "description": "Each save writes to the Postgres database of this Supabase project.",
392
+ "project": "Project",
393
+ "host": "Server",
394
+ "openDashboard": "Open the Supabase dashboard"
389
395
  }
390
396
  }
391
397
  }
@@ -386,6 +386,12 @@
386
386
  "noProject": "O Pluto não conseguiu ler a referência do projeto na sua URL do Supabase. Abra o painel do Supabase manualmente.",
387
387
  "rateLimitHint": "Depois de configurar o SMTP próprio, abra Authentication, depois Rate Limits, e aumente o número de e-mails por hora. O padrão é baixo."
388
388
  }
389
+ },
390
+ "backend": {
391
+ "description": "Cada salvamento grava no banco Postgres deste projeto do Supabase.",
392
+ "project": "Projeto",
393
+ "host": "Servidor",
394
+ "openDashboard": "Abrir o painel do Supabase"
389
395
  }
390
396
  }
391
397
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/supabase",
3
3
  "type": "module",
4
- "version": "0.21.0",
4
+ "version": "0.23.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.41.0 <1.0.0",
33
+ "@plutocms/core": ">=0.52.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.
@@ -408,7 +415,9 @@ async function create(
408
415
  throw toContentError(error)
409
416
  }
410
417
 
411
- return toItem(ctx.type, data)
418
+ const item = toItem(ctx.type, data)
419
+ await recordRevision(ctx, client, item, 'create')
420
+ return item
412
421
  }
413
422
 
414
423
  async function update(
@@ -435,21 +444,15 @@ async function update(
435
444
  row[updatedColumn] = new Date().toISOString()
436
445
  }
437
446
 
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
447
+ // A payload with a status reads the stored row first: the published-at
448
+ // rule and the revision action need the old status. Selects the whole row
449
+ // rather than the one dynamic column name: postgrest-js parses a
450
+ // `select()` argument at the type level, and a non-literal (runtime)
451
+ // column name resolves to its own `GenericStringError` type instead of a
452
+ // usable row shape.
453
+ let existing: Record<string, unknown> | null = null
454
+ if (statusColumn && Object.hasOwn(values, 'status')) {
455
+ const { data: stored, error: fetchError } = await client
453
456
  .from(ctx.type.source)
454
457
  .select('*')
455
458
  .eq(pkColumn, id)
@@ -459,12 +462,21 @@ async function update(
459
462
  throw toContentError(fetchError)
460
463
  }
461
464
 
462
- if (
463
- existing &&
464
- (existing as Record<string, unknown>)[publishedAtColumn] == null
465
- ) {
466
- row[publishedAtColumn] = new Date().toISOString()
467
- }
465
+ existing = stored as Record<string, unknown> | null
466
+ }
467
+
468
+ // Published-at preservation, generalizing supabase-blog's hand-written
469
+ // edit route: stamp published-at the first time a row is published, but
470
+ // never overwrite an already-set value on a later edit.
471
+ if (
472
+ statusColumn &&
473
+ ctx.type.status &&
474
+ ctx.type.status.publishedAtColumn &&
475
+ row[statusColumn] === ctx.type.status.publishedValue &&
476
+ existing &&
477
+ existing[ctx.type.status.publishedAtColumn] == null
478
+ ) {
479
+ row[ctx.type.status.publishedAtColumn] = new Date().toISOString()
468
480
  }
469
481
 
470
482
  const { data, error } = await client
@@ -478,7 +490,18 @@ async function update(
478
490
  throw toContentError(error)
479
491
  }
480
492
 
481
- return toItem(ctx.type, data)
493
+ const item = toItem(ctx.type, data)
494
+ await recordRevision(
495
+ ctx,
496
+ client,
497
+ item,
498
+ updateAction(
499
+ ctx.type,
500
+ existing && statusColumn ? existing[statusColumn] : undefined,
501
+ statusColumn && Object.hasOwn(values, 'status') ? values.status : undefined
502
+ )
503
+ )
504
+ return item
482
505
  }
483
506
 
484
507
  async function remove(
@@ -493,6 +516,36 @@ async function remove(
493
516
  if (error) {
494
517
  throw toContentError(error)
495
518
  }
519
+
520
+ await deleteRevisions(ctx, client, id)
521
+ }
522
+
523
+ /** The saved versions of one item, newest first. See `content-revisions.ts`. */
524
+ async function listRevisions(
525
+ ctx: PlutoContentContext,
526
+ id: string | number,
527
+ query: { limit: number; cursor?: string }
528
+ ): Promise<PlutoContentRevisionPage> {
529
+ const client = await getClient(ctx.event)
530
+ try {
531
+ return await listItemRevisions(ctx, client, id, query)
532
+ } catch (error) {
533
+ throw toContentError(error as PostgrestError)
534
+ }
535
+ }
536
+
537
+ /** One item as it was at a revision. See `content-revisions.ts`. */
538
+ async function getRevision(
539
+ ctx: PlutoContentContext,
540
+ id: string | number,
541
+ revisionId: string
542
+ ): Promise<PlutoContentItem | null> {
543
+ const client = await getClient(ctx.event)
544
+ try {
545
+ return await getItemRevision(ctx, client, id, revisionId)
546
+ } catch (error) {
547
+ throw toContentError(error as PostgrestError)
548
+ }
496
549
  }
497
550
 
498
551
  /**
@@ -541,5 +594,7 @@ export function createSupabaseContentAdapter(): PlutoContentAdapter {
541
594
  authorize,
542
595
  countCreated,
543
596
  timestampKeys,
597
+ listRevisions,
598
+ getRevision,
544
599
  }
545
600
  }
@@ -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
+ }