@plutocms/supabase 0.24.0 → 0.26.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 +14 -0
- package/FEATURES.md +3 -1
- package/db/migrations/012_content_backups.sql +55 -0
- package/package.json +2 -2
- package/server/utils/content-adapter.ts +23 -0
- package/server/utils/content-backups.ts +220 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.26.0](https://github.com/plutocms/supabase/compare/v0.25.0...v0.26.0) (2026-11-07)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **supabase:** the status filter of the admin list ([#248](https://github.com/plutocms/supabase/issues/248)) ([b9f0d4e](https://github.com/plutocms/supabase/commit/b9f0d4e47b951b31061aa31400a152d51abe3850))
|
|
9
|
+
|
|
10
|
+
## [0.25.0](https://github.com/plutocms/supabase/compare/v0.24.0...v0.25.0) (2026-11-06)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **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))
|
|
16
|
+
|
|
3
17
|
## [0.24.0](https://github.com/plutocms/supabase/compare/v0.23.0...v0.24.0) (2026-11-06)
|
|
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 (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.
|
|
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.
|
|
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.
|
|
4
|
+
"version": "0.26.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.61.1 <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,
|
|
@@ -285,6 +286,11 @@ async function list(
|
|
|
285
286
|
builder = builder.eq(statusColumn, publishedValue)
|
|
286
287
|
}
|
|
287
288
|
|
|
289
|
+
// Only one status. The status column has a default and no null, so an equality is enough.
|
|
290
|
+
if (statusColumn && query.status !== undefined) {
|
|
291
|
+
builder = builder.eq(statusColumn, query.status)
|
|
292
|
+
}
|
|
293
|
+
|
|
288
294
|
for (const [field, value] of Object.entries(query.filters ?? {})) {
|
|
289
295
|
const column = resolveFieldColumn(ctx.type, field, 'filter')
|
|
290
296
|
|
|
@@ -571,6 +577,22 @@ async function getRevision(
|
|
|
571
577
|
}
|
|
572
578
|
}
|
|
573
579
|
|
|
580
|
+
/** The server copy of the unsaved changes of the caller. See `content-backups.ts`. */
|
|
581
|
+
const backups = {
|
|
582
|
+
async get(ctx: PlutoContentContext, key: string) {
|
|
583
|
+
const client = await getClient(ctx.event)
|
|
584
|
+
return await getBackup(ctx, client, key)
|
|
585
|
+
},
|
|
586
|
+
async put(ctx: PlutoContentContext, key: string, record: PlutoContentBackup) {
|
|
587
|
+
const client = await getClient(ctx.event)
|
|
588
|
+
return await putBackup(ctx, client, key, record)
|
|
589
|
+
},
|
|
590
|
+
async remove(ctx: PlutoContentContext, key: string) {
|
|
591
|
+
const client = await getClient(ctx.event)
|
|
592
|
+
await removeBackup(ctx, client, key)
|
|
593
|
+
},
|
|
594
|
+
}
|
|
595
|
+
|
|
574
596
|
/**
|
|
575
597
|
* Counts the rows created in `[range.from, range.to)` with one exact-count
|
|
576
598
|
* HEAD query: no rows come back, so it is not capped by the PostgREST row
|
|
@@ -619,5 +641,6 @@ export function createSupabaseContentAdapter(): PlutoContentAdapter {
|
|
|
619
641
|
timestampKeys,
|
|
620
642
|
listRevisions,
|
|
621
643
|
getRevision,
|
|
644
|
+
backups,
|
|
622
645
|
}
|
|
623
646
|
}
|
|
@@ -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
|
+
}
|