@plutocms/supabase 0.27.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 +7 -0
- package/FEATURES.md +1 -1
- package/package.json +2 -2
- package/server/utils/content-adapter.ts +154 -16
- package/shared/types/supabase.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
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
|
+
|
|
3
10
|
## [0.27.0](https://github.com/plutocms/supabase/compare/v0.26.0...v0.27.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, 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.
|
|
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",
|
|
@@ -295,6 +295,90 @@ function isListField(type: PlutoContentType, fieldName: string): boolean {
|
|
|
295
295
|
return field?.type === 'reference' && field.multiple === true
|
|
296
296
|
}
|
|
297
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
|
+
|
|
298
382
|
async function list(
|
|
299
383
|
ctx: PlutoContentContext,
|
|
300
384
|
query: PlutoContentQuery
|
|
@@ -318,9 +402,7 @@ async function list(
|
|
|
318
402
|
|
|
319
403
|
const statusColumn = resolveStatusColumn(ctx.type)
|
|
320
404
|
if (statusColumn && query.includeUnpublished !== true) {
|
|
321
|
-
|
|
322
|
-
(ctx.type.status && ctx.type.status.publishedValue) || 'published'
|
|
323
|
-
builder = builder.eq(statusColumn, publishedValue)
|
|
405
|
+
builder = liveStatusFilter(builder, ctx.type, statusColumn)
|
|
324
406
|
}
|
|
325
407
|
|
|
326
408
|
// Only one status. The status column has a default and no null, so an equality is enough.
|
|
@@ -458,6 +540,18 @@ async function create(
|
|
|
458
540
|
} else if (ctx.type.status && ctx.type.status.default !== undefined) {
|
|
459
541
|
row[statusColumn] = ctx.type.status.default
|
|
460
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
|
+
}
|
|
461
555
|
}
|
|
462
556
|
|
|
463
557
|
// A client never controls its own creation timestamp. Always stamp it,
|
|
@@ -513,7 +607,10 @@ async function update(
|
|
|
513
607
|
// column name resolves to its own `GenericStringError` type instead of a
|
|
514
608
|
// usable row shape.
|
|
515
609
|
let existing: Record<string, unknown> | null = null
|
|
516
|
-
|
|
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)) {
|
|
517
614
|
const { data: stored, error: fetchError } = await client
|
|
518
615
|
.from(ctx.type.source)
|
|
519
616
|
.select('*')
|
|
@@ -527,18 +624,27 @@ async function update(
|
|
|
527
624
|
existing = stored as Record<string, unknown> | null
|
|
528
625
|
}
|
|
529
626
|
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
+
}
|
|
542
648
|
}
|
|
543
649
|
|
|
544
650
|
const { data, error } = await client
|
|
@@ -830,6 +936,37 @@ async function authorize(event: H3Event, capability: string): Promise<void> {
|
|
|
830
936
|
}
|
|
831
937
|
|
|
832
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
|
+
|
|
833
970
|
export function createSupabaseContentAdapter(): PlutoContentAdapter {
|
|
834
971
|
return {
|
|
835
972
|
id: 'supabase',
|
|
@@ -845,6 +982,7 @@ export function createSupabaseContentAdapter(): PlutoContentAdapter {
|
|
|
845
982
|
purge,
|
|
846
983
|
purgeBefore,
|
|
847
984
|
},
|
|
985
|
+
publishDue,
|
|
848
986
|
authorize,
|
|
849
987
|
countCreated,
|
|
850
988
|
timestampKeys,
|
package/shared/types/supabase.ts
CHANGED
|
@@ -189,7 +189,9 @@ export type Database = {
|
|
|
189
189
|
is_custom: boolean
|
|
190
190
|
name: string
|
|
191
191
|
price: number
|
|
192
|
+
published_at: string | null
|
|
192
193
|
slug: string
|
|
194
|
+
status: string
|
|
193
195
|
stock_quantity: number | null
|
|
194
196
|
}
|
|
195
197
|
Insert: {
|
|
@@ -202,7 +204,9 @@ export type Database = {
|
|
|
202
204
|
is_custom?: boolean
|
|
203
205
|
name: string
|
|
204
206
|
price: number
|
|
207
|
+
published_at?: string | null
|
|
205
208
|
slug: string
|
|
209
|
+
status?: string
|
|
206
210
|
stock_quantity?: number | null
|
|
207
211
|
}
|
|
208
212
|
Update: {
|
|
@@ -215,7 +219,9 @@ export type Database = {
|
|
|
215
219
|
is_custom?: boolean
|
|
216
220
|
name?: string
|
|
217
221
|
price?: number
|
|
222
|
+
published_at?: string | null
|
|
218
223
|
slug?: string
|
|
224
|
+
status?: string
|
|
219
225
|
stock_quantity?: number | null
|
|
220
226
|
}
|
|
221
227
|
Relationships: [
|