@plutocms/supabase 0.26.0 → 0.27.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 +7 -0
- package/FEATURES.md +1 -1
- package/package.json +2 -2
- package/server/utils/content-adapter.ts +225 -16
- package/shared/types/supabase.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.27.0](https://github.com/plutocms/supabase/compare/v0.26.0...v0.27.0) (2026-11-07)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* the trash on Supabase (blog migration 009, shop migration 007) ([#252](https://github.com/plutocms/supabase/issues/252)) ([4821321](https://github.com/plutocms/supabase/commit/48213213721f58b1022a0a98e9c3c32f98c6a873))
|
|
9
|
+
|
|
3
10
|
## [0.26.0](https://github.com/plutocms/supabase/compare/v0.25.0...v0.26.0) (2026-11-07)
|
|
4
11
|
|
|
5
12
|
|
package/FEATURES.md
CHANGED
|
@@ -16,7 +16,7 @@ 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 (a filter on a `multiple` reference matches a list column), the `status` of the admin list and `ids`, 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), the `status` of the admin list, the trash (a `deleted_at` column, the service client for the scheduled purge) and `ids`, puts the timestamps on items, and reads the public API of core with an anonymous client.
|
|
20
20
|
It keeps the revisions of every item (`pluto_content_revisions`, migration 011) for the History of core.
|
|
21
21
|
It keeps a server copy of the unsaved changes of each editor (`pluto_content_backups`, migration
|
|
22
22
|
012), so the edit form can offer them on another device.
|
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.27.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.62.0 <1.0.0",
|
|
34
34
|
"@supabase/supabase-js": "^2.117.3",
|
|
35
35
|
"postgres": "^3.4.9",
|
|
36
36
|
"supabase": "^2.121.0",
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
recordRevision,
|
|
12
12
|
updateAction,
|
|
13
13
|
} from './content-revisions'
|
|
14
|
+
import { optionalServiceClient } from './service-client'
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* A Supabase client for one request, deliberately untyped.
|
|
@@ -40,6 +41,19 @@ import {
|
|
|
40
41
|
* type, not to assert against one.
|
|
41
42
|
*/
|
|
42
43
|
async function getClient(event: H3Event): Promise<SupabaseClient> {
|
|
44
|
+
// A system request (a scheduled task of core, such as the purge of the trash) has no user: it
|
|
45
|
+
// needs the service role key. Without the key it answers 401, and the task skips the type.
|
|
46
|
+
if (isPlutoSystemEvent(event)) {
|
|
47
|
+
const service = optionalServiceClient(event)
|
|
48
|
+
if (!service) {
|
|
49
|
+
throw createPlutoError({
|
|
50
|
+
statusCode: 401,
|
|
51
|
+
message: 'A system request needs the Supabase service role key.',
|
|
52
|
+
key: 'pluto.errors.unauthorized',
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
return service as unknown as SupabaseClient
|
|
56
|
+
}
|
|
43
57
|
if (isPublicRead(event)) {
|
|
44
58
|
return getAnonymousClient(event)
|
|
45
59
|
}
|
|
@@ -254,6 +268,26 @@ function resolveFieldColumn(
|
|
|
254
268
|
return fieldName
|
|
255
269
|
}
|
|
256
270
|
|
|
271
|
+
/**
|
|
272
|
+
* The column that marks a trashed row: `type.trash.column`, else `deleted_at`. `undefined` for a
|
|
273
|
+
* type with no trash. See the "Trash" of the content-model skill of core.
|
|
274
|
+
*/
|
|
275
|
+
export function resolveTrashColumn(type: PlutoContentType): string | undefined {
|
|
276
|
+
if (!type.trash) {
|
|
277
|
+
return undefined
|
|
278
|
+
}
|
|
279
|
+
return (typeof type.trash === 'object' && type.trash.column) || 'deleted_at'
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Keeps the rows that are not in the trash, for a type with a trash. */
|
|
283
|
+
function liveOnly<B extends { is: (column: string, value: null) => B }>(
|
|
284
|
+
builder: B,
|
|
285
|
+
type: PlutoContentType
|
|
286
|
+
): B {
|
|
287
|
+
const column = resolveTrashColumn(type)
|
|
288
|
+
return column ? builder.is(column, null) : builder
|
|
289
|
+
}
|
|
290
|
+
|
|
257
291
|
/** Whether a field stores a list in one column: a `multiple` reference. */
|
|
258
292
|
function isListField(type: PlutoContentType, fieldName: string): boolean {
|
|
259
293
|
const field = type.fields.find((candidate) => candidate.name === fieldName)
|
|
@@ -273,7 +307,10 @@ async function list(
|
|
|
273
307
|
const client = await getClient(ctx.event)
|
|
274
308
|
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
275
309
|
|
|
276
|
-
let builder =
|
|
310
|
+
let builder = liveOnly(
|
|
311
|
+
client.from(ctx.type.source).select('*', { count: 'exact' }),
|
|
312
|
+
ctx.type
|
|
313
|
+
)
|
|
277
314
|
|
|
278
315
|
if (query.ids) {
|
|
279
316
|
builder = builder.in(pkColumn, query.ids)
|
|
@@ -362,11 +399,10 @@ async function get(
|
|
|
362
399
|
const client = await getClient(ctx.event)
|
|
363
400
|
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
364
401
|
|
|
365
|
-
const { data, error } = await
|
|
366
|
-
.from(ctx.type.source)
|
|
367
|
-
.
|
|
368
|
-
|
|
369
|
-
.maybeSingle()
|
|
402
|
+
const { data, error } = await liveOnly(
|
|
403
|
+
client.from(ctx.type.source).select('*').eq(pkColumn, idOrSlug),
|
|
404
|
+
ctx.type
|
|
405
|
+
).maybeSingle()
|
|
370
406
|
|
|
371
407
|
if (error && !isInvalidIdShape(error)) {
|
|
372
408
|
throw toContentError(error)
|
|
@@ -383,11 +419,10 @@ async function get(
|
|
|
383
419
|
'slug.field'
|
|
384
420
|
)
|
|
385
421
|
|
|
386
|
-
const bySlug = await
|
|
387
|
-
.from(ctx.type.source)
|
|
388
|
-
.
|
|
389
|
-
|
|
390
|
-
.maybeSingle()
|
|
422
|
+
const bySlug = await liveOnly(
|
|
423
|
+
client.from(ctx.type.source).select('*').eq(slugColumn, idOrSlug),
|
|
424
|
+
ctx.type
|
|
425
|
+
).maybeSingle()
|
|
391
426
|
|
|
392
427
|
if (bySlug.error) {
|
|
393
428
|
throw toContentError(bySlug.error)
|
|
@@ -609,11 +644,14 @@ async function countCreated(
|
|
|
609
644
|
}
|
|
610
645
|
|
|
611
646
|
const client = await getClient(ctx.event)
|
|
612
|
-
const { count, error } = await
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
647
|
+
const { count, error } = await liveOnly(
|
|
648
|
+
client
|
|
649
|
+
.from(ctx.type.source)
|
|
650
|
+
.select('*', { count: 'exact', head: true })
|
|
651
|
+
.gte(createdColumn, range.from)
|
|
652
|
+
.lt(createdColumn, range.to),
|
|
653
|
+
ctx.type
|
|
654
|
+
)
|
|
617
655
|
|
|
618
656
|
if (error) {
|
|
619
657
|
throw toContentError(error)
|
|
@@ -622,6 +660,170 @@ async function countCreated(
|
|
|
622
660
|
return count ?? 0
|
|
623
661
|
}
|
|
624
662
|
|
|
663
|
+
// --- Trash ------------------------------------------------------------------
|
|
664
|
+
//
|
|
665
|
+
// A trashed row has its trash column set (`resolveTrashColumn`). Moving it to the trash and back
|
|
666
|
+
// is an update of that column; the purge is the real delete, so the delete triggers of the layers
|
|
667
|
+
// (the references to a term or an author) run then. The migrations of the layers give the column
|
|
668
|
+
// and the policies: visitors never read a trashed row, and staff with the delete capability does.
|
|
669
|
+
|
|
670
|
+
function requireTrashColumn(type: PlutoContentType): string {
|
|
671
|
+
const column = resolveTrashColumn(type)
|
|
672
|
+
if (!column) {
|
|
673
|
+
throw createPlutoError({
|
|
674
|
+
statusCode: 404,
|
|
675
|
+
message: 'This content type has no trash.',
|
|
676
|
+
key: 'pluto.errors.noTrash',
|
|
677
|
+
})
|
|
678
|
+
}
|
|
679
|
+
return column
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function notFound() {
|
|
683
|
+
return createPlutoError({
|
|
684
|
+
statusCode: 404,
|
|
685
|
+
message: 'Not found.',
|
|
686
|
+
key: 'pluto.errors.notFound',
|
|
687
|
+
})
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async function moveToTrash(
|
|
691
|
+
ctx: PlutoContentContext,
|
|
692
|
+
id: string | number
|
|
693
|
+
): Promise<void> {
|
|
694
|
+
const column = requireTrashColumn(ctx.type)
|
|
695
|
+
const client = await getClient(ctx.event)
|
|
696
|
+
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
697
|
+
|
|
698
|
+
const { data, error } = await client
|
|
699
|
+
.from(ctx.type.source)
|
|
700
|
+
.update({ [column]: new Date().toISOString() })
|
|
701
|
+
.eq(pkColumn, id)
|
|
702
|
+
.is(column, null)
|
|
703
|
+
.select('*')
|
|
704
|
+
|
|
705
|
+
if (error) {
|
|
706
|
+
throw toContentError(error)
|
|
707
|
+
}
|
|
708
|
+
if (!data || data.length === 0) {
|
|
709
|
+
throw notFound()
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function listTrash(
|
|
714
|
+
ctx: PlutoContentContext,
|
|
715
|
+
query: { limit?: number; offset?: number; search?: string }
|
|
716
|
+
): Promise<PlutoContentListResult> {
|
|
717
|
+
const column = requireTrashColumn(ctx.type)
|
|
718
|
+
const client = await getClient(ctx.event)
|
|
719
|
+
|
|
720
|
+
let builder = client
|
|
721
|
+
.from(ctx.type.source)
|
|
722
|
+
.select('*', { count: 'exact' })
|
|
723
|
+
.not(column, 'is', null)
|
|
724
|
+
|
|
725
|
+
if (query.search) {
|
|
726
|
+
const titleColumn = resolveFieldColumn(
|
|
727
|
+
ctx.type,
|
|
728
|
+
ctx.type.titleField,
|
|
729
|
+
'titleField'
|
|
730
|
+
)
|
|
731
|
+
builder = builder.ilike(titleColumn, `%${query.search}%`)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
builder = builder.order(column, { ascending: false })
|
|
735
|
+
|
|
736
|
+
const offset = query.offset ?? 0
|
|
737
|
+
const limit = query.limit ?? 50
|
|
738
|
+
builder = builder.range(offset, offset + limit - 1)
|
|
739
|
+
|
|
740
|
+
const { data, error, count } = await builder
|
|
741
|
+
if (error) {
|
|
742
|
+
throw toContentError(error)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
return {
|
|
746
|
+
data: (data ?? []).map((row: Record<string, unknown>) => ({
|
|
747
|
+
...toItem(ctx.type, row),
|
|
748
|
+
_deletedAt: row[column] ?? null,
|
|
749
|
+
})),
|
|
750
|
+
total: count ?? undefined,
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function restoreFromTrash(
|
|
755
|
+
ctx: PlutoContentContext,
|
|
756
|
+
id: string | number
|
|
757
|
+
): Promise<PlutoContentItem> {
|
|
758
|
+
const column = requireTrashColumn(ctx.type)
|
|
759
|
+
const client = await getClient(ctx.event)
|
|
760
|
+
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
761
|
+
|
|
762
|
+
const { data, error } = await client
|
|
763
|
+
.from(ctx.type.source)
|
|
764
|
+
.update({ [column]: null })
|
|
765
|
+
.eq(pkColumn, id)
|
|
766
|
+
.not(column, 'is', null)
|
|
767
|
+
.select('*')
|
|
768
|
+
|
|
769
|
+
if (error) {
|
|
770
|
+
throw toContentError(error)
|
|
771
|
+
}
|
|
772
|
+
const row = data?.[0]
|
|
773
|
+
if (!row) {
|
|
774
|
+
throw notFound()
|
|
775
|
+
}
|
|
776
|
+
return toItem(ctx.type, row)
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function purge(
|
|
780
|
+
ctx: PlutoContentContext,
|
|
781
|
+
id: string | number
|
|
782
|
+
): Promise<void> {
|
|
783
|
+
const column = requireTrashColumn(ctx.type)
|
|
784
|
+
const client = await getClient(ctx.event)
|
|
785
|
+
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
786
|
+
|
|
787
|
+
const { data, error } = await client
|
|
788
|
+
.from(ctx.type.source)
|
|
789
|
+
.delete()
|
|
790
|
+
.eq(pkColumn, id)
|
|
791
|
+
.not(column, 'is', null)
|
|
792
|
+
.select('*')
|
|
793
|
+
|
|
794
|
+
if (error) {
|
|
795
|
+
throw toContentError(error)
|
|
796
|
+
}
|
|
797
|
+
if (!data || data.length === 0) {
|
|
798
|
+
throw notFound()
|
|
799
|
+
}
|
|
800
|
+
await deleteRevisions(ctx, client, id)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function purgeBefore(
|
|
804
|
+
ctx: PlutoContentContext,
|
|
805
|
+
before: string
|
|
806
|
+
): Promise<number> {
|
|
807
|
+
const column = requireTrashColumn(ctx.type)
|
|
808
|
+
const client = await getClient(ctx.event)
|
|
809
|
+
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
810
|
+
|
|
811
|
+
const { data, error } = await client
|
|
812
|
+
.from(ctx.type.source)
|
|
813
|
+
.delete()
|
|
814
|
+
.lt(column, before)
|
|
815
|
+
.select('*')
|
|
816
|
+
|
|
817
|
+
if (error) {
|
|
818
|
+
throw toContentError(error)
|
|
819
|
+
}
|
|
820
|
+
const rows = (data ?? []) as Record<string, unknown>[]
|
|
821
|
+
for (const row of rows) {
|
|
822
|
+
await deleteRevisions(ctx, client, row[pkColumn] as string | number)
|
|
823
|
+
}
|
|
824
|
+
return rows.length
|
|
825
|
+
}
|
|
826
|
+
|
|
625
827
|
/** Delegates straight to `requireCapability`, unchanged. */
|
|
626
828
|
async function authorize(event: H3Event, capability: string): Promise<void> {
|
|
627
829
|
await requireCapability(event, capability)
|
|
@@ -636,6 +838,13 @@ export function createSupabaseContentAdapter(): PlutoContentAdapter {
|
|
|
636
838
|
create,
|
|
637
839
|
update,
|
|
638
840
|
remove,
|
|
841
|
+
trash: {
|
|
842
|
+
move: moveToTrash,
|
|
843
|
+
list: listTrash,
|
|
844
|
+
restore: restoreFromTrash,
|
|
845
|
+
purge,
|
|
846
|
+
purgeBefore,
|
|
847
|
+
},
|
|
639
848
|
authorize,
|
|
640
849
|
countCreated,
|
|
641
850
|
timestampKeys,
|
package/shared/types/supabase.ts
CHANGED
|
@@ -89,18 +89,21 @@ export type Database = {
|
|
|
89
89
|
product_availability: {
|
|
90
90
|
Row: {
|
|
91
91
|
created_at: string
|
|
92
|
+
deleted_at: string | null
|
|
92
93
|
id: number
|
|
93
94
|
label: string
|
|
94
95
|
slug: string
|
|
95
96
|
}
|
|
96
97
|
Insert: {
|
|
97
98
|
created_at?: string
|
|
99
|
+
deleted_at?: string | null
|
|
98
100
|
id?: never
|
|
99
101
|
label: string
|
|
100
102
|
slug: string
|
|
101
103
|
}
|
|
102
104
|
Update: {
|
|
103
105
|
created_at?: string
|
|
106
|
+
deleted_at?: string | null
|
|
104
107
|
id?: never
|
|
105
108
|
label?: string
|
|
106
109
|
slug?: string
|
|
@@ -109,18 +112,21 @@ export type Database = {
|
|
|
109
112
|
}
|
|
110
113
|
product_category: {
|
|
111
114
|
Row: {
|
|
115
|
+
deleted_at: string | null
|
|
112
116
|
description: string | null
|
|
113
117
|
id: number
|
|
114
118
|
label: string
|
|
115
119
|
slug: string
|
|
116
120
|
}
|
|
117
121
|
Insert: {
|
|
122
|
+
deleted_at?: string | null
|
|
118
123
|
description?: string | null
|
|
119
124
|
id?: never
|
|
120
125
|
label: string
|
|
121
126
|
slug: string
|
|
122
127
|
}
|
|
123
128
|
Update: {
|
|
129
|
+
deleted_at?: string | null
|
|
124
130
|
description?: string | null
|
|
125
131
|
id?: never
|
|
126
132
|
label?: string
|
|
@@ -177,6 +183,7 @@ export type Database = {
|
|
|
177
183
|
availability: number | null
|
|
178
184
|
category: number | null
|
|
179
185
|
created_at: string
|
|
186
|
+
deleted_at: string | null
|
|
180
187
|
description: string | null
|
|
181
188
|
id: number
|
|
182
189
|
is_custom: boolean
|
|
@@ -189,6 +196,7 @@ export type Database = {
|
|
|
189
196
|
availability?: number | null
|
|
190
197
|
category?: number | null
|
|
191
198
|
created_at?: string
|
|
199
|
+
deleted_at?: string | null
|
|
192
200
|
description?: string | null
|
|
193
201
|
id?: never
|
|
194
202
|
is_custom?: boolean
|
|
@@ -201,6 +209,7 @@ export type Database = {
|
|
|
201
209
|
availability?: number | null
|
|
202
210
|
category?: number | null
|
|
203
211
|
created_at?: string
|
|
212
|
+
deleted_at?: string | null
|
|
204
213
|
description?: string | null
|
|
205
214
|
id?: never
|
|
206
215
|
is_custom?: boolean
|