@plutocms/supabase 0.27.0 → 0.29.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.29.0](https://github.com/plutocms/supabase/compare/v0.28.0...v0.29.0) (2026-11-08)
4
+
5
+
6
+ ### Features
7
+
8
+ * **blog:** embeds, and attribute values escaped once ([#269](https://github.com/plutocms/supabase/issues/269)) ([da339f6](https://github.com/plutocms/supabase/commit/da339f6ce3aeebcb876511d2307e12e972cbc878))
9
+
10
+ ## [0.28.0](https://github.com/plutocms/supabase/compare/v0.27.0...v0.28.0) (2026-11-08)
11
+
12
+
13
+ ### Features
14
+
15
+ * scheduled publish and pending review ([#261](https://github.com/plutocms/supabase/issues/261)) ([2eb8653](https://github.com/plutocms/supabase/commit/2eb8653cd3fec38248c6bf44200c0e55b399afdd))
16
+
3
17
  ## [0.27.0](https://github.com/plutocms/supabase/compare/v0.26.0...v0.27.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, 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.27.0",
4
+ "version": "0.29.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.62.0 <1.0.0",
33
+ "@plutocms/core": ">=0.64.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
- const publishedValue =
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
- if (statusColumn && Object.hasOwn(values, 'status')) {
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
- // Published-at preservation, generalizing supabase-blog's hand-written
531
- // edit route: stamp published-at the first time a row is published, but
532
- // never overwrite an already-set value on a later edit.
533
- if (
534
- statusColumn &&
535
- ctx.type.status &&
536
- ctx.type.status.publishedAtColumn &&
537
- row[statusColumn] === ctx.type.status.publishedValue &&
538
- existing &&
539
- existing[ctx.type.status.publishedAtColumn] == null
540
- ) {
541
- row[ctx.type.status.publishedAtColumn] = new Date().toISOString()
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,
@@ -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: [