@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 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.26.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.61.1 <1.0.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 = client.from(ctx.type.source).select('*', { count: 'exact' })
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 client
366
- .from(ctx.type.source)
367
- .select('*')
368
- .eq(pkColumn, idOrSlug)
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 client
387
- .from(ctx.type.source)
388
- .select('*')
389
- .eq(slugColumn, idOrSlug)
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 client
613
- .from(ctx.type.source)
614
- .select('*', { count: 'exact', head: true })
615
- .gte(createdColumn, range.from)
616
- .lt(createdColumn, range.to)
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,
@@ -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