@plutocms/supabase 0.23.0 → 0.25.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.25.0](https://github.com/plutocms/supabase/compare/v0.24.0...v0.25.0) (2026-11-06)
4
+
5
+
6
+ ### Features
7
+
8
+ * **supabase:** keep a server copy of the unsaved changes of an editor ([#234](https://github.com/plutocms/supabase/issues/234)) ([bd7d06f](https://github.com/plutocms/supabase/commit/bd7d06f5094d43e98e406d05b2af99bd4a5962ce))
9
+
10
+ ## [0.24.0](https://github.com/plutocms/supabase/compare/v0.23.0...v0.24.0) (2026-11-06)
11
+
12
+
13
+ ### Features
14
+
15
+ * **supabase-blog:** tags and categories for posts ([#225](https://github.com/plutocms/supabase/issues/225)) ([36263de](https://github.com/plutocms/supabase/commit/36263def50c4f1e1a4226be5cd14b270d27bb097))
16
+
3
17
  ## [0.23.0](https://github.com/plutocms/supabase/compare/v0.22.0...v0.23.0) (2026-11-03)
4
18
 
5
19
 
package/FEATURES.md CHANGED
@@ -16,8 +16,10 @@ 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, 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) 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
+ It keeps a server copy of the unsaved changes of each editor (`pluto_content_backups`, migration
22
+ 012), so the edit form can offer them on another device.
21
23
  @.claude/skills/content-adapter/SKILL.md
22
24
  - Auth driver: plugs Supabase sign-in into the admin shell that pluto core owns (navbar, sign-in
23
25
  page, route guard), keeps the setup guard in the driver, and registers the backend badge
@@ -0,0 +1,55 @@
1
+ -- The server copy of the unsaved changes of an editor (the content-backup skill of
2
+ -- @plutocms/core). The edit form sends the copy while the editor types, so the editor can pick the
3
+ -- changes up again on another device. One row for each user, content type and item. Only the owner
4
+ -- reads or writes a row. Every content type of every layer shares this table. Every statement is
5
+ -- idempotent.
6
+
7
+ create table if not exists public.pluto_content_backups (
8
+ id bigint generated by default as identity not null,
9
+ user_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
10
+ type text not null, -- the content type name
11
+ item_key text not null, -- the item id as text, '+new' for a new item
12
+ device text not null, -- the device id of the record
13
+ saved_at timestamp with time zone not null, -- savedAt of the record (the browser clock)
14
+ updated_at timestamp with time zone not null default now(), -- the server time of the write
15
+ record jsonb not null,
16
+ constraint pluto_content_backups_pkey primary key (id),
17
+ constraint pluto_content_backups_item_key unique (user_id, type, item_key),
18
+ constraint pluto_content_backups_item_key_length check (char_length(item_key) between 1 and 500),
19
+ constraint pluto_content_backups_record_size check (octet_length(record::text) <= 5000000)
20
+ );
21
+
22
+ create index if not exists pluto_content_backups_user_idx
23
+ on public.pluto_content_backups (user_id, updated_at desc);
24
+
25
+ alter table public.pluto_content_backups enable row level security;
26
+
27
+ -- Owner only. The core route checks the write capability of the type, and a row only ever shows
28
+ -- its owner their own data, so the policies hold no capability.
29
+ drop policy if exists "pluto_content_backups_select" on public.pluto_content_backups;
30
+ create policy "pluto_content_backups_select"
31
+ on public.pluto_content_backups for select
32
+ to authenticated
33
+ using (user_id = (select auth.uid()));
34
+
35
+ drop policy if exists "pluto_content_backups_insert" on public.pluto_content_backups;
36
+ create policy "pluto_content_backups_insert"
37
+ on public.pluto_content_backups for insert
38
+ to authenticated
39
+ with check (user_id = (select auth.uid()));
40
+
41
+ drop policy if exists "pluto_content_backups_update" on public.pluto_content_backups;
42
+ create policy "pluto_content_backups_update"
43
+ on public.pluto_content_backups for update
44
+ to authenticated
45
+ using (user_id = (select auth.uid()))
46
+ with check (user_id = (select auth.uid()));
47
+
48
+ drop policy if exists "pluto_content_backups_delete" on public.pluto_content_backups;
49
+ create policy "pluto_content_backups_delete"
50
+ on public.pluto_content_backups for delete
51
+ to authenticated
52
+ using (user_id = (select auth.uid()));
53
+
54
+ grant select, insert, update, delete on public.pluto_content_backups to authenticated;
55
+ grant select, insert, update, delete on public.pluto_content_backups to service_role;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/supabase",
3
3
  "type": "module",
4
- "version": "0.23.0",
4
+ "version": "0.25.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.52.0 <1.0.0",
33
+ "@plutocms/core": ">=0.59.0 <1.0.0",
34
34
  "@supabase/supabase-js": "^2.117.3",
35
35
  "postgres": "^3.4.9",
36
36
  "supabase": "^2.121.0",
@@ -3,6 +3,7 @@ import type { H3Event } from 'h3'
3
3
  import { createClient } from '@supabase/supabase-js'
4
4
  import { serverSupabaseClient } from '#supabase/server'
5
5
  import { requireCapability } from './capability-guard'
6
+ import { getBackup, putBackup, removeBackup } from './content-backups'
6
7
  import {
7
8
  deleteRevisions,
8
9
  getItemRevision,
@@ -253,15 +254,31 @@ function resolveFieldColumn(
253
254
  return fieldName
254
255
  }
255
256
 
257
+ /** Whether a field stores a list in one column: a `multiple` reference. */
258
+ function isListField(type: PlutoContentType, fieldName: string): boolean {
259
+ const field = type.fields.find((candidate) => candidate.name === fieldName)
260
+
261
+ return field?.type === 'reference' && field.multiple === true
262
+ }
263
+
256
264
  async function list(
257
265
  ctx: PlutoContentContext,
258
266
  query: PlutoContentQuery
259
267
  ): Promise<PlutoContentListResult> {
268
+ // An empty id list matches no item: no query.
269
+ if (query.ids && query.ids.length === 0) {
270
+ return { data: [], total: 0 }
271
+ }
272
+
260
273
  const client = await getClient(ctx.event)
261
274
  const pkColumn = ctx.type.primaryKey ?? 'id'
262
275
 
263
276
  let builder = client.from(ctx.type.source).select('*', { count: 'exact' })
264
277
 
278
+ if (query.ids) {
279
+ builder = builder.in(pkColumn, query.ids)
280
+ }
281
+
265
282
  const statusColumn = resolveStatusColumn(ctx.type)
266
283
  if (statusColumn && query.includeUnpublished !== true) {
267
284
  const publishedValue =
@@ -270,7 +287,12 @@ async function list(
270
287
  }
271
288
 
272
289
  for (const [field, value] of Object.entries(query.filters ?? {})) {
273
- builder = builder.eq(resolveFieldColumn(ctx.type, field, 'filter'), value)
290
+ const column = resolveFieldColumn(ctx.type, field, 'filter')
291
+
292
+ // A list column (a `multiple` reference) matches when it holds the value.
293
+ builder = isListField(ctx.type, field)
294
+ ? builder.contains(column, [value])
295
+ : builder.eq(column, value)
274
296
  }
275
297
 
276
298
  if (query.search) {
@@ -498,7 +520,9 @@ async function update(
498
520
  updateAction(
499
521
  ctx.type,
500
522
  existing && statusColumn ? existing[statusColumn] : undefined,
501
- statusColumn && Object.hasOwn(values, 'status') ? values.status : undefined
523
+ statusColumn && Object.hasOwn(values, 'status')
524
+ ? values.status
525
+ : undefined
502
526
  )
503
527
  )
504
528
  return item
@@ -548,6 +572,22 @@ async function getRevision(
548
572
  }
549
573
  }
550
574
 
575
+ /** The server copy of the unsaved changes of the caller. See `content-backups.ts`. */
576
+ const backups = {
577
+ async get(ctx: PlutoContentContext, key: string) {
578
+ const client = await getClient(ctx.event)
579
+ return await getBackup(ctx, client, key)
580
+ },
581
+ async put(ctx: PlutoContentContext, key: string, record: PlutoContentBackup) {
582
+ const client = await getClient(ctx.event)
583
+ return await putBackup(ctx, client, key, record)
584
+ },
585
+ async remove(ctx: PlutoContentContext, key: string) {
586
+ const client = await getClient(ctx.event)
587
+ await removeBackup(ctx, client, key)
588
+ },
589
+ }
590
+
551
591
  /**
552
592
  * Counts the rows created in `[range.from, range.to)` with one exact-count
553
593
  * HEAD query: no rows come back, so it is not capped by the PostgREST row
@@ -596,5 +636,6 @@ export function createSupabaseContentAdapter(): PlutoContentAdapter {
596
636
  timestampKeys,
597
637
  listRevisions,
598
638
  getRevision,
639
+ backups,
599
640
  }
600
641
  }
@@ -0,0 +1,220 @@
1
+ import type { SupabaseClient } from '@supabase/supabase-js'
2
+ import type { H3Event } from 'h3'
3
+ import { serverSupabaseUser } from '#supabase/server'
4
+
5
+ /**
6
+ * The server copy of the unsaved changes of an editor (012_content_backups.sql). Core's edit form
7
+ * sends it through `PlutoContentAdapter.backups`. One row for each user, type and item, readable
8
+ * only by its owner (Row Level Security). See the content-adapter skill ("Backups").
9
+ */
10
+
11
+ export const BACKUPS_TABLE = 'pluto_content_backups'
12
+
13
+ /** The most copies kept for one user. Older ones (by `updated_at`) are deleted after a write. */
14
+ export const MAX_BACKUPS_PER_USER = 20
15
+
16
+ /** A copy expires after this many days. */
17
+ export const BACKUP_TTL_DAYS = 14
18
+
19
+ /** `otherDevice` is true when the replaced copy came from another device within this time. */
20
+ export const OTHER_DEVICE_WINDOW_MS = 10 * 60 * 1000
21
+
22
+ /** The most rows one cleanup deletes. A later write deletes the rest. */
23
+ const PRUNE_BATCH = 100
24
+
25
+ const DAY_MS = 24 * 60 * 60 * 1000
26
+
27
+ let warned = false
28
+
29
+ /**
30
+ * True when the error says the table does not exist: Postgres `42P01`, or PostgREST `PGRST205`
31
+ * (the table is not in the schema cache). This is the case before migration 012 is applied.
32
+ */
33
+ export function isMissingTable(error: unknown): boolean {
34
+ const code = (error as { code?: string } | null)?.code
35
+ return code === '42P01' || code === 'PGRST205'
36
+ }
37
+
38
+ /** Warns one time for each process that the table is missing. A save never fails for a backup. */
39
+ function warnMissingTable(): void {
40
+ if (warned) {
41
+ return
42
+ }
43
+ warned = true
44
+ console.warn(
45
+ '[content-backups] The table pluto_content_backups does not exist. Apply migration 012 from /admin/migrations to keep server copies of unsaved changes.'
46
+ )
47
+ }
48
+
49
+ /** Resets the one-warning flag. For tests. */
50
+ export function resetMissingTableWarning(): void {
51
+ warned = false
52
+ }
53
+
54
+ /** The id of the signed-in user, or `null`. */
55
+ async function userIdOf(event: H3Event): Promise<string | null> {
56
+ const user = (await serverSupabaseUser(event)) as {
57
+ sub?: string
58
+ id?: string
59
+ } | null
60
+ return user?.sub ?? user?.id ?? null
61
+ }
62
+
63
+ /** The ISO time of the oldest row that is still valid. */
64
+ function expiryOf(now: number): string {
65
+ return new Date(now - BACKUP_TTL_DAYS * DAY_MS).toISOString()
66
+ }
67
+
68
+ /**
69
+ * The copy of the caller for one item, or `null`. It first deletes the expired copies of the
70
+ * caller. With no user, or before 012 is applied, it answers `null`.
71
+ */
72
+ export async function getBackup(
73
+ ctx: PlutoContentContext,
74
+ client: SupabaseClient,
75
+ key: string
76
+ ): Promise<PlutoContentBackup | null> {
77
+ const userId = await userIdOf(ctx.event)
78
+ if (!userId) {
79
+ return null
80
+ }
81
+ try {
82
+ const { error: expiredError } = await client
83
+ .from(BACKUPS_TABLE)
84
+ .delete()
85
+ .eq('user_id', userId)
86
+ .lt('updated_at', expiryOf(Date.now()))
87
+ if (expiredError) {
88
+ throw expiredError
89
+ }
90
+ const { data, error } = await client
91
+ .from(BACKUPS_TABLE)
92
+ .select('record')
93
+ .eq('user_id', userId)
94
+ .eq('type', ctx.type.name)
95
+ .eq('item_key', key)
96
+ .maybeSingle()
97
+ if (error) {
98
+ throw error
99
+ }
100
+ return (data as { record?: PlutoContentBackup } | null)?.record ?? null
101
+ } catch (error) {
102
+ if (isMissingTable(error)) {
103
+ warnMissingTable()
104
+ return null
105
+ }
106
+ throw error
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Replaces the copy of the caller for one item, then deletes the expired copies and the copies
112
+ * above `MAX_BACKUPS_PER_USER`. `otherDevice` is true when the replaced copy had another `device`
113
+ * and a recent `updated_at`. With no user, or before 012 is applied, it does nothing.
114
+ */
115
+ export async function putBackup(
116
+ ctx: PlutoContentContext,
117
+ client: SupabaseClient,
118
+ key: string,
119
+ record: PlutoContentBackup
120
+ ): Promise<{ otherDevice: boolean }> {
121
+ const userId = await userIdOf(ctx.event)
122
+ if (!userId) {
123
+ return { otherDevice: false }
124
+ }
125
+ try {
126
+ const { data: current, error: currentError } = await client
127
+ .from(BACKUPS_TABLE)
128
+ .select('device, updated_at')
129
+ .eq('user_id', userId)
130
+ .eq('type', ctx.type.name)
131
+ .eq('item_key', key)
132
+ .maybeSingle()
133
+ if (currentError) {
134
+ throw currentError
135
+ }
136
+ const now = Date.now()
137
+ const replaced = current as { device?: string; updated_at?: string } | null
138
+ const otherDevice =
139
+ Boolean(replaced) &&
140
+ replaced!.device !== record.device &&
141
+ now - new Date(replaced!.updated_at ?? 0).getTime() <=
142
+ OTHER_DEVICE_WINDOW_MS
143
+
144
+ const { error } = await client.from(BACKUPS_TABLE).upsert(
145
+ {
146
+ user_id: userId,
147
+ type: ctx.type.name,
148
+ item_key: key,
149
+ device: record.device,
150
+ saved_at: new Date(record.savedAt).toISOString(),
151
+ updated_at: new Date(now).toISOString(),
152
+ record,
153
+ },
154
+ { onConflict: 'user_id,type,item_key' }
155
+ )
156
+ if (error) {
157
+ throw error
158
+ }
159
+
160
+ // The cleanup is best effort: a failure here must not fail the write.
161
+ const { error: expiredError } = await client
162
+ .from(BACKUPS_TABLE)
163
+ .delete()
164
+ .eq('user_id', userId)
165
+ .lt('updated_at', expiryOf(now))
166
+ if (expiredError) {
167
+ console.warn(
168
+ `[content-backups] Could not delete the expired copies: ${expiredError.message}`
169
+ )
170
+ }
171
+ const { data: old, error: oldError } = await client
172
+ .from(BACKUPS_TABLE)
173
+ .select('id')
174
+ .eq('user_id', userId)
175
+ .order('updated_at', { ascending: false })
176
+ .range(MAX_BACKUPS_PER_USER, MAX_BACKUPS_PER_USER + PRUNE_BATCH - 1)
177
+ if (oldError) {
178
+ console.warn(
179
+ `[content-backups] Could not list the old copies: ${oldError.message}`
180
+ )
181
+ } else {
182
+ const ids = (old ?? []).map((row: { id: number }) => row.id)
183
+ if (ids.length > 0) {
184
+ await client.from(BACKUPS_TABLE).delete().in('id', ids)
185
+ }
186
+ }
187
+ return { otherDevice }
188
+ } catch (error) {
189
+ if (isMissingTable(error)) {
190
+ warnMissingTable()
191
+ return { otherDevice: false }
192
+ }
193
+ throw error
194
+ }
195
+ }
196
+
197
+ /** Deletes the copy of the caller for one item. With no user, or before 012, it does nothing. */
198
+ export async function removeBackup(
199
+ ctx: PlutoContentContext,
200
+ client: SupabaseClient,
201
+ key: string
202
+ ): Promise<void> {
203
+ const userId = await userIdOf(ctx.event)
204
+ if (!userId) {
205
+ return
206
+ }
207
+ const { error } = await client
208
+ .from(BACKUPS_TABLE)
209
+ .delete()
210
+ .eq('user_id', userId)
211
+ .eq('type', ctx.type.name)
212
+ .eq('item_key', key)
213
+ if (error) {
214
+ if (isMissingTable(error)) {
215
+ warnMissingTable()
216
+ return
217
+ }
218
+ throw error
219
+ }
220
+ }