@plutocms/supabase 0.26.0 → 0.28.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 +1 -1
- package/package.json +2 -2
- package/server/utils/content-adapter.ts +379 -32
- package/shared/types/supabase.ts +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.28.0](https://github.com/plutocms/supabase/compare/v0.27.0...v0.28.0) (2026-11-08)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* scheduled publish and pending review ([#261](https://github.com/plutocms/supabase/issues/261)) ([2eb8653](https://github.com/plutocms/supabase/commit/2eb8653cd3fec38248c6bf44200c0e55b399afdd))
|
|
9
|
+
|
|
10
|
+
## [0.27.0](https://github.com/plutocms/supabase/compare/v0.26.0...v0.27.0) (2026-11-07)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* 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))
|
|
16
|
+
|
|
3
17
|
## [0.26.0](https://github.com/plutocms/supabase/compare/v0.25.0...v0.26.0) (2026-11-07)
|
|
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), 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), scheduled publish and review (the live rule, the publish time, `publishDue`) 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.28.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.63.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)
|
|
@@ -261,6 +295,90 @@ function isListField(type: PlutoContentType, fieldName: string): boolean {
|
|
|
261
295
|
return field?.type === 'reference' && field.multiple === true
|
|
262
296
|
}
|
|
263
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Only the live rows: published, or scheduled with a time that has come (`isLiveStatus` of core).
|
|
300
|
+
* The read policies of the database have the same rule (`public.pluto_is_live`).
|
|
301
|
+
*/
|
|
302
|
+
function liveStatusFilter<
|
|
303
|
+
B extends {
|
|
304
|
+
eq: (column: string, value: unknown) => B
|
|
305
|
+
or: (filters: string) => B
|
|
306
|
+
},
|
|
307
|
+
>(builder: B, type: PlutoContentType, statusColumn: string): B {
|
|
308
|
+
const rules = statusRules(type)!
|
|
309
|
+
if (rules.scheduled === undefined || !rules.publishedAtColumn) {
|
|
310
|
+
return builder.eq(statusColumn, rules.published)
|
|
311
|
+
}
|
|
312
|
+
const now = new Date().toISOString()
|
|
313
|
+
return builder.or(
|
|
314
|
+
`${statusColumn}.eq.${rules.published},and(${statusColumn}.eq.${rules.scheduled},${rules.publishedAtColumn}.lte."${now}")`
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function publishCapability(type: PlutoContentType): string | undefined {
|
|
319
|
+
return type.capabilities?.publish ?? type.capabilities?.write
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The publish time of a save, the same rule as the Git CMS. A scheduled save sets the planned time
|
|
324
|
+
* (`values.publishAt`, in the future; else 400). A scheduled item that keeps its status keeps its
|
|
325
|
+
* time when the payload has no new one. Publish now of a scheduled item stamps the time now, and a
|
|
326
|
+
* scheduled item that goes to a draft or to review loses its time. Else the first publish stamps
|
|
327
|
+
* the time. Answers the value to write, `null` to clear, or `undefined` to leave the column.
|
|
328
|
+
*/
|
|
329
|
+
function publishTimeFor(
|
|
330
|
+
type: PlutoContentType,
|
|
331
|
+
before: unknown,
|
|
332
|
+
after: unknown,
|
|
333
|
+
storedTime: unknown,
|
|
334
|
+
values: Record<string, unknown>
|
|
335
|
+
): string | null | undefined {
|
|
336
|
+
const rules = statusRules(type)
|
|
337
|
+
if (!rules?.publishedAtColumn || after === undefined) {
|
|
338
|
+
return undefined
|
|
339
|
+
}
|
|
340
|
+
const now = Date.now()
|
|
341
|
+
if (rules.scheduled !== undefined && after === rules.scheduled) {
|
|
342
|
+
if (before !== rules.scheduled || Object.hasOwn(values, 'publishAt')) {
|
|
343
|
+
const key = scheduleError(type, values.publishAt, now)
|
|
344
|
+
if (key) {
|
|
345
|
+
throw createPlutoError({
|
|
346
|
+
statusCode: 400,
|
|
347
|
+
message: 'The schedule time is not valid.',
|
|
348
|
+
key,
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
return new Date(statusTime(values.publishAt)!).toISOString()
|
|
352
|
+
}
|
|
353
|
+
return undefined
|
|
354
|
+
}
|
|
355
|
+
if (rules.scheduled !== undefined && before === rules.scheduled) {
|
|
356
|
+
return after === rules.published ? new Date(now).toISOString() : null
|
|
357
|
+
}
|
|
358
|
+
if (after === rules.published && storedTime == null) {
|
|
359
|
+
return new Date(now).toISOString()
|
|
360
|
+
}
|
|
361
|
+
return undefined
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* A clear 403 before the write, when the status change needs the publish capability. The database
|
|
366
|
+
* refuses it too (`posts_status_guard`), but with a less clear error.
|
|
367
|
+
*/
|
|
368
|
+
async function requireStatusChange(
|
|
369
|
+
ctx: PlutoContentContext,
|
|
370
|
+
before: unknown,
|
|
371
|
+
after: unknown
|
|
372
|
+
): Promise<void> {
|
|
373
|
+
if (isPlutoSystemEvent(ctx.event)) {
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
const capability = publishCapability(ctx.type)
|
|
377
|
+
if (capability && statusChangeNeedsPublish(ctx.type, before, after)) {
|
|
378
|
+
await requireCapability(ctx.event, capability)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
264
382
|
async function list(
|
|
265
383
|
ctx: PlutoContentContext,
|
|
266
384
|
query: PlutoContentQuery
|
|
@@ -273,7 +391,10 @@ async function list(
|
|
|
273
391
|
const client = await getClient(ctx.event)
|
|
274
392
|
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
275
393
|
|
|
276
|
-
let builder =
|
|
394
|
+
let builder = liveOnly(
|
|
395
|
+
client.from(ctx.type.source).select('*', { count: 'exact' }),
|
|
396
|
+
ctx.type
|
|
397
|
+
)
|
|
277
398
|
|
|
278
399
|
if (query.ids) {
|
|
279
400
|
builder = builder.in(pkColumn, query.ids)
|
|
@@ -281,9 +402,7 @@ async function list(
|
|
|
281
402
|
|
|
282
403
|
const statusColumn = resolveStatusColumn(ctx.type)
|
|
283
404
|
if (statusColumn && query.includeUnpublished !== true) {
|
|
284
|
-
|
|
285
|
-
(ctx.type.status && ctx.type.status.publishedValue) || 'published'
|
|
286
|
-
builder = builder.eq(statusColumn, publishedValue)
|
|
405
|
+
builder = liveStatusFilter(builder, ctx.type, statusColumn)
|
|
287
406
|
}
|
|
288
407
|
|
|
289
408
|
// Only one status. The status column has a default and no null, so an equality is enough.
|
|
@@ -362,11 +481,10 @@ async function get(
|
|
|
362
481
|
const client = await getClient(ctx.event)
|
|
363
482
|
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
364
483
|
|
|
365
|
-
const { data, error } = await
|
|
366
|
-
.from(ctx.type.source)
|
|
367
|
-
.
|
|
368
|
-
|
|
369
|
-
.maybeSingle()
|
|
484
|
+
const { data, error } = await liveOnly(
|
|
485
|
+
client.from(ctx.type.source).select('*').eq(pkColumn, idOrSlug),
|
|
486
|
+
ctx.type
|
|
487
|
+
).maybeSingle()
|
|
370
488
|
|
|
371
489
|
if (error && !isInvalidIdShape(error)) {
|
|
372
490
|
throw toContentError(error)
|
|
@@ -383,11 +501,10 @@ async function get(
|
|
|
383
501
|
'slug.field'
|
|
384
502
|
)
|
|
385
503
|
|
|
386
|
-
const bySlug = await
|
|
387
|
-
.from(ctx.type.source)
|
|
388
|
-
.
|
|
389
|
-
|
|
390
|
-
.maybeSingle()
|
|
504
|
+
const bySlug = await liveOnly(
|
|
505
|
+
client.from(ctx.type.source).select('*').eq(slugColumn, idOrSlug),
|
|
506
|
+
ctx.type
|
|
507
|
+
).maybeSingle()
|
|
391
508
|
|
|
392
509
|
if (bySlug.error) {
|
|
393
510
|
throw toContentError(bySlug.error)
|
|
@@ -423,6 +540,18 @@ async function create(
|
|
|
423
540
|
} else if (ctx.type.status && ctx.type.status.default !== undefined) {
|
|
424
541
|
row[statusColumn] = ctx.type.status.default
|
|
425
542
|
}
|
|
543
|
+
await requireStatusChange(ctx, undefined, row[statusColumn])
|
|
544
|
+
const timeColumn = statusRules(ctx.type)?.publishedAtColumn
|
|
545
|
+
const time = publishTimeFor(
|
|
546
|
+
ctx.type,
|
|
547
|
+
undefined,
|
|
548
|
+
row[statusColumn],
|
|
549
|
+
undefined,
|
|
550
|
+
values
|
|
551
|
+
)
|
|
552
|
+
if (timeColumn && time !== undefined) {
|
|
553
|
+
row[timeColumn] = time
|
|
554
|
+
}
|
|
426
555
|
}
|
|
427
556
|
|
|
428
557
|
// A client never controls its own creation timestamp. Always stamp it,
|
|
@@ -478,7 +607,10 @@ async function update(
|
|
|
478
607
|
// column name resolves to its own `GenericStringError` type instead of a
|
|
479
608
|
// usable row shape.
|
|
480
609
|
let existing: Record<string, unknown> | null = null
|
|
481
|
-
|
|
610
|
+
const publishApart =
|
|
611
|
+
ctx.type.capabilities?.publish !== undefined &&
|
|
612
|
+
ctx.type.capabilities.publish !== ctx.type.capabilities.write
|
|
613
|
+
if (statusColumn && (Object.hasOwn(values, 'status') || publishApart)) {
|
|
482
614
|
const { data: stored, error: fetchError } = await client
|
|
483
615
|
.from(ctx.type.source)
|
|
484
616
|
.select('*')
|
|
@@ -492,18 +624,27 @@ async function update(
|
|
|
492
624
|
existing = stored as Record<string, unknown> | null
|
|
493
625
|
}
|
|
494
626
|
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
627
|
+
// The status rules and the publish time (`publishTimeFor`): the first publish stamps the time
|
|
628
|
+
// and a later edit never overwrites it; a schedule sets the planned time.
|
|
629
|
+
if (statusColumn && existing) {
|
|
630
|
+
const before =
|
|
631
|
+
existing[statusColumn] ??
|
|
632
|
+
(ctx.type.status ? ctx.type.status.default : undefined)
|
|
633
|
+
const after = Object.hasOwn(values, 'status') ? values.status : before
|
|
634
|
+
await requireStatusChange(ctx, before, after)
|
|
635
|
+
const timeColumn = statusRules(ctx.type)?.publishedAtColumn
|
|
636
|
+
if (timeColumn && Object.hasOwn(values, 'status')) {
|
|
637
|
+
const time = publishTimeFor(
|
|
638
|
+
ctx.type,
|
|
639
|
+
before,
|
|
640
|
+
after,
|
|
641
|
+
existing[timeColumn],
|
|
642
|
+
values
|
|
643
|
+
)
|
|
644
|
+
if (time !== undefined) {
|
|
645
|
+
row[timeColumn] = time
|
|
646
|
+
}
|
|
647
|
+
}
|
|
507
648
|
}
|
|
508
649
|
|
|
509
650
|
const { data, error } = await client
|
|
@@ -609,11 +750,14 @@ async function countCreated(
|
|
|
609
750
|
}
|
|
610
751
|
|
|
611
752
|
const client = await getClient(ctx.event)
|
|
612
|
-
const { count, error } = await
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
753
|
+
const { count, error } = await liveOnly(
|
|
754
|
+
client
|
|
755
|
+
.from(ctx.type.source)
|
|
756
|
+
.select('*', { count: 'exact', head: true })
|
|
757
|
+
.gte(createdColumn, range.from)
|
|
758
|
+
.lt(createdColumn, range.to),
|
|
759
|
+
ctx.type
|
|
760
|
+
)
|
|
617
761
|
|
|
618
762
|
if (error) {
|
|
619
763
|
throw toContentError(error)
|
|
@@ -622,12 +766,207 @@ async function countCreated(
|
|
|
622
766
|
return count ?? 0
|
|
623
767
|
}
|
|
624
768
|
|
|
769
|
+
// --- Trash ------------------------------------------------------------------
|
|
770
|
+
//
|
|
771
|
+
// A trashed row has its trash column set (`resolveTrashColumn`). Moving it to the trash and back
|
|
772
|
+
// is an update of that column; the purge is the real delete, so the delete triggers of the layers
|
|
773
|
+
// (the references to a term or an author) run then. The migrations of the layers give the column
|
|
774
|
+
// and the policies: visitors never read a trashed row, and staff with the delete capability does.
|
|
775
|
+
|
|
776
|
+
function requireTrashColumn(type: PlutoContentType): string {
|
|
777
|
+
const column = resolveTrashColumn(type)
|
|
778
|
+
if (!column) {
|
|
779
|
+
throw createPlutoError({
|
|
780
|
+
statusCode: 404,
|
|
781
|
+
message: 'This content type has no trash.',
|
|
782
|
+
key: 'pluto.errors.noTrash',
|
|
783
|
+
})
|
|
784
|
+
}
|
|
785
|
+
return column
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function notFound() {
|
|
789
|
+
return createPlutoError({
|
|
790
|
+
statusCode: 404,
|
|
791
|
+
message: 'Not found.',
|
|
792
|
+
key: 'pluto.errors.notFound',
|
|
793
|
+
})
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async function moveToTrash(
|
|
797
|
+
ctx: PlutoContentContext,
|
|
798
|
+
id: string | number
|
|
799
|
+
): Promise<void> {
|
|
800
|
+
const column = requireTrashColumn(ctx.type)
|
|
801
|
+
const client = await getClient(ctx.event)
|
|
802
|
+
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
803
|
+
|
|
804
|
+
const { data, error } = await client
|
|
805
|
+
.from(ctx.type.source)
|
|
806
|
+
.update({ [column]: new Date().toISOString() })
|
|
807
|
+
.eq(pkColumn, id)
|
|
808
|
+
.is(column, null)
|
|
809
|
+
.select('*')
|
|
810
|
+
|
|
811
|
+
if (error) {
|
|
812
|
+
throw toContentError(error)
|
|
813
|
+
}
|
|
814
|
+
if (!data || data.length === 0) {
|
|
815
|
+
throw notFound()
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async function listTrash(
|
|
820
|
+
ctx: PlutoContentContext,
|
|
821
|
+
query: { limit?: number; offset?: number; search?: string }
|
|
822
|
+
): Promise<PlutoContentListResult> {
|
|
823
|
+
const column = requireTrashColumn(ctx.type)
|
|
824
|
+
const client = await getClient(ctx.event)
|
|
825
|
+
|
|
826
|
+
let builder = client
|
|
827
|
+
.from(ctx.type.source)
|
|
828
|
+
.select('*', { count: 'exact' })
|
|
829
|
+
.not(column, 'is', null)
|
|
830
|
+
|
|
831
|
+
if (query.search) {
|
|
832
|
+
const titleColumn = resolveFieldColumn(
|
|
833
|
+
ctx.type,
|
|
834
|
+
ctx.type.titleField,
|
|
835
|
+
'titleField'
|
|
836
|
+
)
|
|
837
|
+
builder = builder.ilike(titleColumn, `%${query.search}%`)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
builder = builder.order(column, { ascending: false })
|
|
841
|
+
|
|
842
|
+
const offset = query.offset ?? 0
|
|
843
|
+
const limit = query.limit ?? 50
|
|
844
|
+
builder = builder.range(offset, offset + limit - 1)
|
|
845
|
+
|
|
846
|
+
const { data, error, count } = await builder
|
|
847
|
+
if (error) {
|
|
848
|
+
throw toContentError(error)
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
return {
|
|
852
|
+
data: (data ?? []).map((row: Record<string, unknown>) => ({
|
|
853
|
+
...toItem(ctx.type, row),
|
|
854
|
+
_deletedAt: row[column] ?? null,
|
|
855
|
+
})),
|
|
856
|
+
total: count ?? undefined,
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async function restoreFromTrash(
|
|
861
|
+
ctx: PlutoContentContext,
|
|
862
|
+
id: string | number
|
|
863
|
+
): Promise<PlutoContentItem> {
|
|
864
|
+
const column = requireTrashColumn(ctx.type)
|
|
865
|
+
const client = await getClient(ctx.event)
|
|
866
|
+
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
867
|
+
|
|
868
|
+
const { data, error } = await client
|
|
869
|
+
.from(ctx.type.source)
|
|
870
|
+
.update({ [column]: null })
|
|
871
|
+
.eq(pkColumn, id)
|
|
872
|
+
.not(column, 'is', null)
|
|
873
|
+
.select('*')
|
|
874
|
+
|
|
875
|
+
if (error) {
|
|
876
|
+
throw toContentError(error)
|
|
877
|
+
}
|
|
878
|
+
const row = data?.[0]
|
|
879
|
+
if (!row) {
|
|
880
|
+
throw notFound()
|
|
881
|
+
}
|
|
882
|
+
return toItem(ctx.type, row)
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async function purge(
|
|
886
|
+
ctx: PlutoContentContext,
|
|
887
|
+
id: string | number
|
|
888
|
+
): Promise<void> {
|
|
889
|
+
const column = requireTrashColumn(ctx.type)
|
|
890
|
+
const client = await getClient(ctx.event)
|
|
891
|
+
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
892
|
+
|
|
893
|
+
const { data, error } = await client
|
|
894
|
+
.from(ctx.type.source)
|
|
895
|
+
.delete()
|
|
896
|
+
.eq(pkColumn, id)
|
|
897
|
+
.not(column, 'is', null)
|
|
898
|
+
.select('*')
|
|
899
|
+
|
|
900
|
+
if (error) {
|
|
901
|
+
throw toContentError(error)
|
|
902
|
+
}
|
|
903
|
+
if (!data || data.length === 0) {
|
|
904
|
+
throw notFound()
|
|
905
|
+
}
|
|
906
|
+
await deleteRevisions(ctx, client, id)
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async function purgeBefore(
|
|
910
|
+
ctx: PlutoContentContext,
|
|
911
|
+
before: string
|
|
912
|
+
): Promise<number> {
|
|
913
|
+
const column = requireTrashColumn(ctx.type)
|
|
914
|
+
const client = await getClient(ctx.event)
|
|
915
|
+
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
916
|
+
|
|
917
|
+
const { data, error } = await client
|
|
918
|
+
.from(ctx.type.source)
|
|
919
|
+
.delete()
|
|
920
|
+
.lt(column, before)
|
|
921
|
+
.select('*')
|
|
922
|
+
|
|
923
|
+
if (error) {
|
|
924
|
+
throw toContentError(error)
|
|
925
|
+
}
|
|
926
|
+
const rows = (data ?? []) as Record<string, unknown>[]
|
|
927
|
+
for (const row of rows) {
|
|
928
|
+
await deleteRevisions(ctx, client, row[pkColumn] as string | number)
|
|
929
|
+
}
|
|
930
|
+
return rows.length
|
|
931
|
+
}
|
|
932
|
+
|
|
625
933
|
/** Delegates straight to `requireCapability`, unchanged. */
|
|
626
934
|
async function authorize(event: H3Event, capability: string): Promise<void> {
|
|
627
935
|
await requireCapability(event, capability)
|
|
628
936
|
}
|
|
629
937
|
|
|
630
938
|
/** Builds the Supabase/PostgREST-backed `PlutoContentAdapter`. Register it with `registerContentAdapter` from a `server/plugins/*.ts` file. */
|
|
939
|
+
/**
|
|
940
|
+
* Publishes the scheduled rows whose time is `at` or earlier, and keeps their time. The scheduled
|
|
941
|
+
* task of core gives a system request (the service client); the admin list of a publisher gives
|
|
942
|
+
* the client of the user. Trashed rows stay.
|
|
943
|
+
*/
|
|
944
|
+
async function publishDue(
|
|
945
|
+
ctx: PlutoContentContext,
|
|
946
|
+
at: string
|
|
947
|
+
): Promise<number> {
|
|
948
|
+
const rules = statusRules(ctx.type)
|
|
949
|
+
const statusColumn = resolveStatusColumn(ctx.type)
|
|
950
|
+
if (!rules?.scheduled || !rules.publishedAtColumn || !statusColumn) {
|
|
951
|
+
return 0
|
|
952
|
+
}
|
|
953
|
+
const client = await getClient(ctx.event)
|
|
954
|
+
const pkColumn = ctx.type.primaryKey ?? 'id'
|
|
955
|
+
const { data, error } = await liveOnly(
|
|
956
|
+
client
|
|
957
|
+
.from(ctx.type.source)
|
|
958
|
+
.update({ [statusColumn]: rules.published })
|
|
959
|
+
.eq(statusColumn, rules.scheduled)
|
|
960
|
+
.lte(rules.publishedAtColumn, at),
|
|
961
|
+
ctx.type
|
|
962
|
+
).select(pkColumn)
|
|
963
|
+
|
|
964
|
+
if (error) {
|
|
965
|
+
throw toContentError(error)
|
|
966
|
+
}
|
|
967
|
+
return data?.length ?? 0
|
|
968
|
+
}
|
|
969
|
+
|
|
631
970
|
export function createSupabaseContentAdapter(): PlutoContentAdapter {
|
|
632
971
|
return {
|
|
633
972
|
id: 'supabase',
|
|
@@ -636,6 +975,14 @@ export function createSupabaseContentAdapter(): PlutoContentAdapter {
|
|
|
636
975
|
create,
|
|
637
976
|
update,
|
|
638
977
|
remove,
|
|
978
|
+
trash: {
|
|
979
|
+
move: moveToTrash,
|
|
980
|
+
list: listTrash,
|
|
981
|
+
restore: restoreFromTrash,
|
|
982
|
+
purge,
|
|
983
|
+
purgeBefore,
|
|
984
|
+
},
|
|
985
|
+
publishDue,
|
|
639
986
|
authorize,
|
|
640
987
|
countCreated,
|
|
641
988
|
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,36 +183,45 @@ 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
|
|
183
190
|
name: string
|
|
184
191
|
price: number
|
|
192
|
+
published_at: string | null
|
|
185
193
|
slug: string
|
|
194
|
+
status: string
|
|
186
195
|
stock_quantity: number | null
|
|
187
196
|
}
|
|
188
197
|
Insert: {
|
|
189
198
|
availability?: number | null
|
|
190
199
|
category?: number | null
|
|
191
200
|
created_at?: string
|
|
201
|
+
deleted_at?: string | null
|
|
192
202
|
description?: string | null
|
|
193
203
|
id?: never
|
|
194
204
|
is_custom?: boolean
|
|
195
205
|
name: string
|
|
196
206
|
price: number
|
|
207
|
+
published_at?: string | null
|
|
197
208
|
slug: string
|
|
209
|
+
status?: string
|
|
198
210
|
stock_quantity?: number | null
|
|
199
211
|
}
|
|
200
212
|
Update: {
|
|
201
213
|
availability?: number | null
|
|
202
214
|
category?: number | null
|
|
203
215
|
created_at?: string
|
|
216
|
+
deleted_at?: string | null
|
|
204
217
|
description?: string | null
|
|
205
218
|
id?: never
|
|
206
219
|
is_custom?: boolean
|
|
207
220
|
name?: string
|
|
208
221
|
price?: number
|
|
222
|
+
published_at?: string | null
|
|
209
223
|
slug?: string
|
|
224
|
+
status?: string
|
|
210
225
|
stock_quantity?: number | null
|
|
211
226
|
}
|
|
212
227
|
Relationships: [
|