@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 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.26.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.61.1 <1.0.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 = client.from(ctx.type.source).select('*', { count: 'exact' })
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
- const publishedValue =
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 client
366
- .from(ctx.type.source)
367
- .select('*')
368
- .eq(pkColumn, idOrSlug)
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 client
387
- .from(ctx.type.source)
388
- .select('*')
389
- .eq(slugColumn, idOrSlug)
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
- 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)) {
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
- // Published-at preservation, generalizing supabase-blog's hand-written
496
- // edit route: stamp published-at the first time a row is published, but
497
- // never overwrite an already-set value on a later edit.
498
- if (
499
- statusColumn &&
500
- ctx.type.status &&
501
- ctx.type.status.publishedAtColumn &&
502
- row[statusColumn] === ctx.type.status.publishedValue &&
503
- existing &&
504
- existing[ctx.type.status.publishedAtColumn] == null
505
- ) {
506
- 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
+ }
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 client
613
- .from(ctx.type.source)
614
- .select('*', { count: 'exact', head: true })
615
- .gte(createdColumn, range.from)
616
- .lt(createdColumn, range.to)
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,
@@ -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: [