@plutocms/supabase 0.24.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,12 @@
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
+
3
10
  ## [0.24.0](https://github.com/plutocms/supabase/compare/v0.23.0...v0.24.0) (2026-11-06)
4
11
 
5
12
 
package/FEATURES.md CHANGED
@@ -18,6 +18,8 @@ policies shared by every project here.
18
18
  declared content type into `@plutocms/core`'s generic content routes. It supports query
19
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.24.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.58.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,
@@ -571,6 +572,22 @@ async function getRevision(
571
572
  }
572
573
  }
573
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
+
574
591
  /**
575
592
  * Counts the rows created in `[range.from, range.to)` with one exact-count
576
593
  * HEAD query: no rows come back, so it is not capped by the PostgREST row
@@ -619,5 +636,6 @@ export function createSupabaseContentAdapter(): PlutoContentAdapter {
619
636
  timestampKeys,
620
637
  listRevisions,
621
638
  getRevision,
639
+ backups,
622
640
  }
623
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
+ }