@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 +14 -0
- package/FEATURES.md +3 -1
- package/README.md +2 -2
- package/app/components/backend/SupabaseBackendDetails.vue +52 -0
- package/app/plugins/pluto-extension.ts +12 -0
- package/db/migrations/011_content_revisions.sql +60 -0
- package/i18n/locales/en.json +6 -0
- package/i18n/locales/pt-BR.json +6 -0
- package/package.json +2 -2
- package/server/utils/content-adapter.ts +78 -23
- package/server/utils/content-revisions.ts +219 -0
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),
|
|
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: [
|
|
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/
|
|
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;
|
package/i18n/locales/en.json
CHANGED
|
@@ -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
|
}
|
package/i18n/locales/pt-BR.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
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
|
+
}
|