@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 +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 +43 -2
- package/server/utils/content-backups.ts +220 -0
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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')
|
|
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
|
+
}
|