@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 +14 -0
- package/FEATURES.md +2 -1
- package/db/migrations/011_content_revisions.sql +60 -0
- package/package.json +2 -2
- package/server/utils/content-adapter.ts +102 -24
- package/server/utils/content-revisions.ts +219 -0
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
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
|
+
}
|