@plutocms/supabase 0.25.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,19 @@
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
+
10
+ ## [0.26.0](https://github.com/plutocms/supabase/compare/v0.25.0...v0.26.0) (2026-11-07)
11
+
12
+
13
+ ### Features
14
+
15
+ * **supabase:** the status filter of the admin list ([#248](https://github.com/plutocms/supabase/issues/248)) ([b9f0d4e](https://github.com/plutocms/supabase/commit/b9f0d4e47b951b31061aa31400a152d51abe3850))
16
+
3
17
  ## [0.25.0](https://github.com/plutocms/supabase/compare/v0.24.0...v0.25.0) (2026-11-06)
4
18
 
5
19
 
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) 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.25.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.59.0 <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)
@@ -286,6 +323,11 @@ async function list(
286
323
  builder = builder.eq(statusColumn, publishedValue)
287
324
  }
288
325
 
326
+ // Only one status. The status column has a default and no null, so an equality is enough.
327
+ if (statusColumn && query.status !== undefined) {
328
+ builder = builder.eq(statusColumn, query.status)
329
+ }
330
+
289
331
  for (const [field, value] of Object.entries(query.filters ?? {})) {
290
332
  const column = resolveFieldColumn(ctx.type, field, 'filter')
291
333
 
@@ -357,11 +399,10 @@ async function get(
357
399
  const client = await getClient(ctx.event)
358
400
  const pkColumn = ctx.type.primaryKey ?? 'id'
359
401
 
360
- const { data, error } = await client
361
- .from(ctx.type.source)
362
- .select('*')
363
- .eq(pkColumn, idOrSlug)
364
- .maybeSingle()
402
+ const { data, error } = await liveOnly(
403
+ client.from(ctx.type.source).select('*').eq(pkColumn, idOrSlug),
404
+ ctx.type
405
+ ).maybeSingle()
365
406
 
366
407
  if (error && !isInvalidIdShape(error)) {
367
408
  throw toContentError(error)
@@ -378,11 +419,10 @@ async function get(
378
419
  'slug.field'
379
420
  )
380
421
 
381
- const bySlug = await client
382
- .from(ctx.type.source)
383
- .select('*')
384
- .eq(slugColumn, idOrSlug)
385
- .maybeSingle()
422
+ const bySlug = await liveOnly(
423
+ client.from(ctx.type.source).select('*').eq(slugColumn, idOrSlug),
424
+ ctx.type
425
+ ).maybeSingle()
386
426
 
387
427
  if (bySlug.error) {
388
428
  throw toContentError(bySlug.error)
@@ -604,11 +644,14 @@ async function countCreated(
604
644
  }
605
645
 
606
646
  const client = await getClient(ctx.event)
607
- const { count, error } = await client
608
- .from(ctx.type.source)
609
- .select('*', { count: 'exact', head: true })
610
- .gte(createdColumn, range.from)
611
- .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
+ )
612
655
 
613
656
  if (error) {
614
657
  throw toContentError(error)
@@ -617,6 +660,170 @@ async function countCreated(
617
660
  return count ?? 0
618
661
  }
619
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
+
620
827
  /** Delegates straight to `requireCapability`, unchanged. */
621
828
  async function authorize(event: H3Event, capability: string): Promise<void> {
622
829
  await requireCapability(event, capability)
@@ -631,6 +838,13 @@ export function createSupabaseContentAdapter(): PlutoContentAdapter {
631
838
  create,
632
839
  update,
633
840
  remove,
841
+ trash: {
842
+ move: moveToTrash,
843
+ list: listTrash,
844
+ restore: restoreFromTrash,
845
+ purge,
846
+ purgeBefore,
847
+ },
634
848
  authorize,
635
849
  countCreated,
636
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