@plutocms/supabase 0.4.1 → 0.6.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.6.0](https://github.com/plutocms/supabase/compare/v0.5.0...v0.6.0) (2026-09-12)
4
+
5
+
6
+ ### Features
7
+
8
+ * **settings:** generic, namespaced setting keys ([#51](https://github.com/plutocms/supabase/issues/51)) ([2410552](https://github.com/plutocms/supabase/commit/2410552ce6c880ecec1fac6f0b84852ef8612bea))
9
+
10
+ ## [0.5.0](https://github.com/plutocms/supabase/compare/v0.4.1...v0.5.0) (2026-09-12)
11
+
12
+
13
+ ### Features
14
+
15
+ * **registry:** migrate to definePlutoExtension ([#49](https://github.com/plutocms/supabase/issues/49)) ([9a4848c](https://github.com/plutocms/supabase/commit/9a4848c2906876bb521fe8ed9dbce433d7110c30))
16
+
3
17
  ## [0.4.1](https://github.com/plutocms/supabase/compare/v0.4.0...v0.4.1) (2026-09-12)
4
18
 
5
19
 
@@ -0,0 +1,30 @@
1
+ <script setup lang="ts">
2
+ const { field } = usePlutoSettings()
3
+
4
+ const websiteTitle = field('website_title')
5
+ const websiteDescription = field('website_description')
6
+ const websiteUrl = field('website_url')
7
+ </script>
8
+
9
+ <template>
10
+ <div class="flex flex-col gap-y-6">
11
+ <UFormField label="Website Title" class="w-full lg:w-1/2">
12
+ <UInput v-model="websiteTitle" placeholder="e.g. My Website" />
13
+ </UFormField>
14
+
15
+ <UFormField label="Website Description" class="w-full lg:w-1/2">
16
+ <UInput
17
+ v-model="websiteDescription"
18
+ placeholder="e.g. My Website Description"
19
+ />
20
+ </UFormField>
21
+
22
+ <UFormField label="Website URL" class="w-full lg:w-1/2">
23
+ <UInput
24
+ v-model="websiteUrl"
25
+ type="url"
26
+ placeholder="e.g. https://example.com"
27
+ />
28
+ </UFormField>
29
+ </div>
30
+ </template>
@@ -0,0 +1,40 @@
1
+ import NavbarAdminProvider from '../components/navbar/NavbarAdminProvider.vue'
2
+ import SettingsGeneralPanel from '../components/settings/SettingsGeneralPanel.vue'
3
+
4
+ export default defineNuxtPlugin(() => {
5
+ definePlutoExtension({
6
+ id: 'supabase',
7
+ navbar: { id: 'admin-navbar', component: NavbarAdminProvider },
8
+ settingsPanels: [
9
+ {
10
+ id: 'general',
11
+ order: 0,
12
+ title: 'General',
13
+ icon: 'lucide:globe',
14
+ component: SettingsGeneralPanel,
15
+ },
16
+ ],
17
+ settingsDriver: {
18
+ id: 'supabase',
19
+ load: async () => {
20
+ const response = await $fetch<{ settings: Record<string, string> }>(
21
+ '/api/settings'
22
+ )
23
+ return response.settings
24
+ },
25
+ save: async (patch) => {
26
+ await $fetch('/api/settings/update', {
27
+ method: 'POST',
28
+ body: patch,
29
+ })
30
+
31
+ // NavbarAdmin.vue watches this to refresh its own /api/settings
32
+ // read (it shows the site's own URL) — the settings page saving
33
+ // through the new registry must still trigger it, exactly like
34
+ // the old page did.
35
+ const hasSettingsModified = useState<number>('has_settings_modified')
36
+ hasSettingsModified.value = Date.now()
37
+ },
38
+ },
39
+ })
40
+ })
@@ -0,0 +1,37 @@
1
+ -- Generic settings keys
2
+ --
3
+ -- public.settings has been a fixed 3-value enum since 001_baseline.sql —
4
+ -- a layer other than @plutocms/supabase could never add its own setting
5
+ -- without altering a shared type. This adds a free-text, namespaced key
6
+ -- column alongside the enum column, without removing the enum column
7
+ -- yet. Dropping setting_name and the TSettings enum is left for a later
8
+ -- migration, once no shipped code writes it, to keep this one cheap to
9
+ -- reason about and its effects easy to undo.
10
+ --
11
+ -- Key format: lowercase segments separated by dots, e.g. 'website_title'
12
+ -- or 'blog.posts_per_page'. A layer other than core must namespace its
13
+ -- keys with its own name, so two layers can never collide.
14
+
15
+ alter table public.settings add column if not exists setting_key text;
16
+
17
+ update public.settings set setting_key = setting_name::text where setting_key is null;
18
+
19
+ alter table public.settings alter column setting_name drop not null;
20
+
21
+ create unique index if not exists settings_setting_key_key on public.settings (setting_key);
22
+
23
+ do $$
24
+ begin
25
+ if not exists (
26
+ select 1 from pg_constraint
27
+ where conname = 'settings_setting_key_format'
28
+ and conrelid = 'public.settings'::regclass
29
+ ) then
30
+ alter table public.settings
31
+ add constraint settings_setting_key_format
32
+ check (setting_key ~ '^[a-z0-9_]+(?:\.[a-z0-9_]+)*$') not valid;
33
+ end if;
34
+ end
35
+ $$;
36
+
37
+ alter table public.settings validate constraint settings_setting_key_format;
package/nuxt.config.ts CHANGED
@@ -35,12 +35,6 @@ export default defineNuxtConfig({
35
35
  },
36
36
  },
37
37
 
38
- vite: {
39
- optimizeDeps: {
40
- include: ['yup'],
41
- },
42
- },
43
-
44
38
  eslint: {
45
39
  config: {
46
40
  nuxt: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/supabase",
3
3
  "type": "module",
4
- "version": "0.4.1",
4
+ "version": "0.6.0",
5
5
  "trustedDependencies": [
6
6
  "@parcel/watcher",
7
7
  "@plutocms/pluto",
@@ -39,11 +39,10 @@
39
39
  "@nuxt/eslint": "^1.16.0",
40
40
  "@nuxt/ui": "^4.9.0",
41
41
  "@nuxtjs/supabase": "^2.0.9",
42
- "@plutocms/pluto": "^0.3.2",
42
+ "@plutocms/pluto": "^0.4.0",
43
43
  "@plutocms/utils": "^0.2.1",
44
44
  "postgres": "^3.4.9",
45
- "supabase": "^2.109.1",
46
- "yup": "1.7.1"
45
+ "supabase": "^2.109.1"
47
46
  },
48
47
  "devDependencies": {
49
48
  "@antfu/eslint-config": "^6.2.0",
@@ -3,24 +3,23 @@ import { serverSupabaseClient } from '#supabase/server'
3
3
  export default defineEventHandler(async (event) => {
4
4
  const client = await serverSupabaseClient<Database>(event)
5
5
 
6
- const { data } = await client.from('settings').select('*')
6
+ const { data } = await client
7
+ .from('settings')
8
+ .select('setting_key, setting_name, setting_value')
7
9
 
8
- type SettingsKey =
9
- Database['public']['Tables']['settings']['Row']['setting_name']
10
-
11
- // @ts-expect-error: Object.fromEntries may not infer the correct type for settings
12
- const settings: Record<SettingsKey, string> = {
13
- ...Object.fromEntries(
14
- data?.map((item) => [item.setting_name, item.setting_value]) || []
15
- ),
16
- }
10
+ const settings: Record<string, string> = {}
17
11
 
18
12
  data?.forEach((item) => {
19
- if (!item.setting_name || !item.setting_value) {
13
+ // setting_key is null only for a row written before
14
+ // db/migrations/003_settings_generic_keys.sql backfilled it — fall
15
+ // back to the enum column so a mid-migration read never drops a row.
16
+ const key = item.setting_key ?? item.setting_name
17
+
18
+ if (!key || !item.setting_value) {
20
19
  return
21
20
  }
22
21
 
23
- settings[item.setting_name] = item.setting_value
22
+ settings[key] = item.setting_value
24
23
  })
25
24
 
26
25
  return { settings }
@@ -1,36 +1,30 @@
1
1
  import { serverSupabaseClient } from '#supabase/server'
2
2
  import { requireAdmin } from '../../utils/admin-guard'
3
3
 
4
- // Mirrors the TSettings enum in db/migrations/001_baseline.sql. The Postgres enum
5
- // already rejects an unknown key at the DB level (as a raw constraint
6
- // error) — this turns that into a clean 400 instead.
7
- const KNOWN_SETTING_KEYS = [
8
- 'website_title',
9
- 'website_url',
10
- 'website_description',
11
- ] as const
4
+ // A key is lowercase segments separated by dots, e.g. 'website_title' or
5
+ // 'blog.posts_per_page'. Mirrors the settings_setting_key_format check
6
+ // constraint in db/migrations/003_settings_generic_keys.sql — this turns
7
+ // a violation into a clean 400 instead of a raw Postgres error. A layer
8
+ // other than core must namespace its own keys so two layers can never
9
+ // collide.
10
+ const SETTING_KEY_PATTERN = /^[a-z0-9_]+(?:\.[a-z0-9_]+)*$/
11
+ const MAX_SETTING_VALUE_LENGTH = 10_000
12
12
 
13
13
  export default defineEventHandler(async (event) => {
14
14
  await requireAdmin(event)
15
15
 
16
- type SettingName =
17
- Database['public']['Tables']['settings']['Insert']['setting_name']
18
- type FormBody = Record<SettingName, string>
19
-
20
16
  const client = await serverSupabaseClient<Database>(event)
21
- const body = await readBody<FormBody>(event)
17
+ const body = await readBody<Record<string, unknown>>(event)
22
18
 
23
19
  if (!body || typeof body !== 'object') {
24
20
  throw createError({ statusCode: 400, statusMessage: 'No payload sent.' })
25
21
  }
26
22
 
27
23
  const transformed = Object.entries(body).map(([key, value]) => {
28
- if (
29
- !KNOWN_SETTING_KEYS.includes(key as (typeof KNOWN_SETTING_KEYS)[number])
30
- ) {
24
+ if (!SETTING_KEY_PATTERN.test(key)) {
31
25
  throw createError({
32
26
  statusCode: 400,
33
- statusMessage: `Unknown setting: ${key}`,
27
+ statusMessage: `Invalid setting key: ${key}`,
34
28
  })
35
29
  }
36
30
 
@@ -41,15 +35,22 @@ export default defineEventHandler(async (event) => {
41
35
  })
42
36
  }
43
37
 
38
+ if (value.length > MAX_SETTING_VALUE_LENGTH) {
39
+ throw createError({
40
+ statusCode: 400,
41
+ statusMessage: `Setting "${key}" is too long.`,
42
+ })
43
+ }
44
+
44
45
  return {
45
- setting_name: key as SettingName,
46
+ setting_key: key,
46
47
  setting_value: value,
47
48
  }
48
49
  })
49
50
 
50
51
  const { data, error } = await client
51
52
  .from('settings')
52
- .upsert(transformed, { onConflict: 'setting_name' })
53
+ .upsert(transformed, { onConflict: 'setting_key' })
53
54
  .select()
54
55
 
55
56
  if (error) {
@@ -256,17 +256,20 @@ export type Database = {
256
256
  settings: {
257
257
  Row: {
258
258
  id: number
259
- setting_name: Database["public"]["Enums"]["tsettings"]
259
+ setting_key: string | null
260
+ setting_name: Database["public"]["Enums"]["tsettings"] | null
260
261
  setting_value: string
261
262
  }
262
263
  Insert: {
263
264
  id?: number
264
- setting_name: Database["public"]["Enums"]["tsettings"]
265
+ setting_key?: string | null
266
+ setting_name?: Database["public"]["Enums"]["tsettings"] | null
265
267
  setting_value: string
266
268
  }
267
269
  Update: {
268
270
  id?: number
269
- setting_name?: Database["public"]["Enums"]["tsettings"]
271
+ setting_key?: string | null
272
+ setting_name?: Database["public"]["Enums"]["tsettings"] | null
270
273
  setting_value?: string
271
274
  }
272
275
  Relationships: []
@@ -1,125 +0,0 @@
1
- <script setup lang="ts">
2
- import { object, string } from 'yup'
3
-
4
- type Form = Partial<
5
- Record<
6
- Database['public']['Tables']['settings']['Insert']['setting_name'],
7
- string
8
- >
9
- >
10
-
11
- useHead({
12
- title: 'Settings',
13
- })
14
-
15
- const toast = useToast()
16
-
17
- const has_settings_modified = useState('has_settings_modified', () => {
18
- return Date.now()
19
- })
20
-
21
- const { data } = await useFetch('/api/settings')
22
-
23
- const schema = object({
24
- website_title: string().required().label('Website Title'),
25
- website_description: string().required().label('Website Description'),
26
- website_url: string().url().required().label('Website URL'),
27
- })
28
-
29
- const form = ref<Form>({
30
- website_title: data.value?.settings.website_title || '',
31
- website_description: data.value?.settings.website_description || '',
32
- website_url: data.value?.settings.website_url || '',
33
- })
34
-
35
- const isSubmitting = ref<boolean>(false)
36
-
37
- async function submitForm() {
38
- const payload = form.value
39
-
40
- isSubmitting.value = true
41
-
42
- try {
43
- await $fetch('/api/settings/update', {
44
- method: 'POST',
45
- body: payload,
46
- })
47
-
48
- toast.add({
49
- title: 'Settings updated successfully',
50
- color: 'success',
51
- })
52
-
53
- has_settings_modified.value = Date.now()
54
- } catch (error) {
55
- if (import.meta.dev) {
56
- console.error('Error updating settings:', error)
57
- }
58
-
59
- toast.add({
60
- title: 'Failed to update settings.',
61
- color: 'error',
62
- })
63
- } finally {
64
- isSubmitting.value = false
65
- }
66
- }
67
- </script>
68
-
69
- <template>
70
- <AdminView>
71
- <h1 class="text-3xl font-bold lg:text-4xl">Settings</h1>
72
-
73
- <UCard>
74
- <UForm :schema="schema" :state="form" @submit="submitForm">
75
- <div class="flex flex-col gap-y-6">
76
- <UFormField
77
- label="Website Title"
78
- name="website_title"
79
- class="w-full lg:w-1/2"
80
- >
81
- <UInput
82
- v-model="form.website_title"
83
- placeholder="e.g. My Website"
84
- />
85
- </UFormField>
86
-
87
- <UFormField
88
- label="Website Description"
89
- name="website_description"
90
- class="w-full lg:w-1/2"
91
- >
92
- <UInput
93
- v-model="form.website_description"
94
- placeholder="e.g. My Website Description"
95
- />
96
- </UFormField>
97
-
98
- <UFormField
99
- label="Website URL"
100
- name="website_url"
101
- class="w-full lg:w-1/2"
102
- >
103
- <UInput
104
- v-model="form.website_url"
105
- type="url"
106
- placeholder="e.g. https://example.com"
107
- />
108
- </UFormField>
109
-
110
- <div class="flex">
111
- <UButton
112
- :loading="isSubmitting"
113
- :disabled="isSubmitting"
114
- type="submit"
115
- icon="lucide:save"
116
- class="w-full justify-center sm:w-auto"
117
- >
118
- Save
119
- </UButton>
120
- </div>
121
- </div>
122
- </UForm>
123
- </UCard>
124
- </AdminView>
125
- </template>
@@ -1,7 +0,0 @@
1
- import NavbarAdminProvider from '../components/navbar/NavbarAdminProvider.vue'
2
-
3
- export default defineNuxtPlugin(() => {
4
- const { registerNavbar } = useNavbarAdmin()
5
-
6
- registerNavbar(NavbarAdminProvider)
7
- })