@meith/web 0.27.0 → 0.28.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.
@@ -4,6 +4,7 @@ import type { TaskHealthStatus } from '@meith/tasks'
4
4
  import { cn } from '@meith/ui'
5
5
 
6
6
  import {
7
+ ApplyMigrationsForm,
7
8
  ClearCacheForm,
8
9
  PruneSessionsForm,
9
10
  PruneTokensForm,
@@ -16,6 +17,7 @@ import { PanelPage } from '@/components/shell/panel-page'
16
17
  import { adminPageContext } from '@/server/admin'
17
18
  import { getTranslator, tr } from '@/server/i18n'
18
19
  import { buildSystemHealthView } from '@/server/system-admin'
20
+ import { CODE_VERSION, pendingUpgradeNotice } from '@/server/upgrade-notice'
19
21
  import { systemFormsCopy } from '@/view/admin-panel-copy'
20
22
  import { formatTime } from '@/view/time'
21
23
 
@@ -36,7 +38,11 @@ export default async function AdminSystemPage() {
36
38
  if ((await adminPageContext()) === null) return null
37
39
 
38
40
  const now = new Date()
39
- const [translator, view] = await Promise.all([getTranslator(), buildSystemHealthView(now)])
41
+ const [translator, view, upgradeNotice] = await Promise.all([
42
+ getTranslator(),
43
+ buildSystemHealthView(now),
44
+ pendingUpgradeNotice(),
45
+ ])
40
46
  const copy = systemFormsCopy(translator)
41
47
 
42
48
  if (view === null) {
@@ -246,6 +252,28 @@ export default async function AdminSystemPage() {
246
252
  </ul>
247
253
  </section>
248
254
 
255
+ <section className={PANEL_CARD}>
256
+ <h2 className="font-heading text-lg font-semibold">
257
+ {translator.t('adminSystem.migrations')}
258
+ </h2>
259
+ <p className="text-sm">
260
+ {translator.t('adminSystem.runningVersion')} <code>{CODE_VERSION}</code>.
261
+ </p>
262
+ {upgradeNotice === null ? (
263
+ <p className="text-sm text-muted-foreground">
264
+ {translator.t('adminSystem.migrationsUpToDate')}
265
+ </p>
266
+ ) : (
267
+ <>
268
+ <p className="text-sm text-destructive">{upgradeNotice}</p>
269
+ <p className="text-sm text-muted-foreground">
270
+ {translator.t('adminSystem.migrationsHint')}
271
+ </p>
272
+ <ApplyMigrationsForm copy={copy} />
273
+ </>
274
+ )}
275
+ </section>
276
+
249
277
  <section className={PANEL_CARD}>
250
278
  <h2 className="font-heading text-lg font-semibold">
251
279
  {translator.t('adminSystem.recount')}
@@ -167,8 +167,8 @@ export default async function AdminThemesPage() {
167
167
  </p>
168
168
  <p className="text-muted-foreground">
169
169
  {translator.t('adminThemes.deployBefore')} <em>exist</em>
170
- {translator.t('adminThemes.deployAfterExist')} <code className="text-xs">pnpm add</code>{' '}
171
- {translator.t('adminThemes.deployAfterAdd')}{' '}
170
+ {translator.t('adminThemes.deployAfterExist')}{' '}
171
+ <code className="text-xs">npm install</code> {translator.t('adminThemes.deployAfterAdd')}{' '}
172
172
  <code className="text-xs">meith.config.ts</code>
173
173
  {translator.t('adminThemes.deployAfterConfig')}{' '}
174
174
  <code className="text-xs">defaultTheme</code> {translator.t('adminThemes.deployEnd')}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/web",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,53 +43,53 @@
43
43
  "react-dom": "19.2.8",
44
44
  "tailwindcss": "^4.3.3",
45
45
  "typescript": "7.0.2",
46
- "@meith/accounts": "0.27.0",
47
- "@meith/admin": "0.27.0",
48
- "@meith/antispam": "0.27.0",
49
- "@meith/api": "0.27.0",
50
- "@meith/attachments": "0.27.0",
51
- "@meith/authorization": "0.27.0",
52
- "@meith/avatars": "0.27.0",
53
- "@meith/core": "0.27.0",
54
- "@meith/db": "0.27.0",
55
- "@meith/demo": "0.27.0",
56
- "@meith/drafts": "0.27.0",
57
- "@meith/drivers": "0.27.0",
58
- "@meith/events": "0.27.0",
59
- "@meith/forums": "0.27.0",
60
- "@meith/groups": "0.27.0",
61
- "@meith/i18n": "0.27.0",
62
- "@meith/import": "0.27.0",
63
- "@meith/install": "0.27.0",
64
- "@meith/mail": "0.27.0",
65
- "@meith/markdown": "0.27.0",
66
- "@meith/marketplace": "0.27.0",
67
- "@meith/messages": "0.27.0",
68
- "@meith/moderation": "0.27.0",
69
- "@meith/notifications": "0.27.0",
70
- "@meith/plugin-calendar": "0.27.0",
71
- "@meith/plugin-dues": "0.27.0",
72
- "@meith/plugin-kit": "0.27.0",
73
- "@meith/polls": "0.27.0",
74
- "@meith/posts": "0.27.0",
75
- "@meith/profile-fields": "0.27.0",
76
- "@meith/relations": "0.27.0",
77
- "@meith/reputation": "0.27.0",
78
- "@meith/runtime": "0.27.0",
79
- "@meith/search": "0.27.0",
80
- "@meith/settings": "0.27.0",
81
- "@meith/signatures": "0.27.0",
82
- "@meith/subscriptions": "0.27.0",
83
- "@meith/tasks": "0.27.0",
84
- "@meith/theme-clubhouse": "0.27.0",
85
- "@meith/theme-default": "0.27.0",
86
- "@meith/theme-kit": "0.27.0",
87
- "@meith/theme-midnight": "0.27.0",
88
- "@meith/theme-phasebook": "0.27.0",
89
- "@meith/theme-raidframe": "0.27.0",
90
- "@meith/threads": "0.27.0",
91
- "@meith/ui": "0.27.0",
92
- "@meith/upgrade": "0.27.0"
46
+ "@meith/accounts": "0.28.0",
47
+ "@meith/admin": "0.28.0",
48
+ "@meith/antispam": "0.28.0",
49
+ "@meith/api": "0.28.0",
50
+ "@meith/attachments": "0.28.0",
51
+ "@meith/authorization": "0.28.0",
52
+ "@meith/avatars": "0.28.0",
53
+ "@meith/core": "0.28.0",
54
+ "@meith/db": "0.28.0",
55
+ "@meith/demo": "0.28.0",
56
+ "@meith/drafts": "0.28.0",
57
+ "@meith/drivers": "0.28.0",
58
+ "@meith/events": "0.28.0",
59
+ "@meith/forums": "0.28.0",
60
+ "@meith/i18n": "0.28.0",
61
+ "@meith/groups": "0.28.0",
62
+ "@meith/import": "0.28.0",
63
+ "@meith/install": "0.28.0",
64
+ "@meith/mail": "0.28.0",
65
+ "@meith/markdown": "0.28.0",
66
+ "@meith/marketplace": "0.28.0",
67
+ "@meith/messages": "0.28.0",
68
+ "@meith/moderation": "0.28.0",
69
+ "@meith/notifications": "0.28.0",
70
+ "@meith/plugin-calendar": "0.28.0",
71
+ "@meith/plugin-dues": "0.28.0",
72
+ "@meith/plugin-kit": "0.28.0",
73
+ "@meith/polls": "0.28.0",
74
+ "@meith/posts": "0.28.0",
75
+ "@meith/profile-fields": "0.28.0",
76
+ "@meith/relations": "0.28.0",
77
+ "@meith/reputation": "0.28.0",
78
+ "@meith/runtime": "0.28.0",
79
+ "@meith/search": "0.28.0",
80
+ "@meith/settings": "0.28.0",
81
+ "@meith/signatures": "0.28.0",
82
+ "@meith/subscriptions": "0.28.0",
83
+ "@meith/tasks": "0.28.0",
84
+ "@meith/theme-clubhouse": "0.28.0",
85
+ "@meith/theme-default": "0.28.0",
86
+ "@meith/theme-kit": "0.28.0",
87
+ "@meith/theme-midnight": "0.28.0",
88
+ "@meith/theme-phasebook": "0.28.0",
89
+ "@meith/theme-raidframe": "0.28.0",
90
+ "@meith/threads": "0.28.0",
91
+ "@meith/ui": "0.28.0",
92
+ "@meith/upgrade": "0.28.0"
93
93
  },
94
94
  "scripts": {
95
95
  "dev": "next dev",
@@ -4,6 +4,7 @@ import { useActionState } from 'react'
4
4
 
5
5
  import { EMPTY_STATE } from '@/server/auth-form-state'
6
6
  import {
7
+ applyUpgradeAction,
7
8
  clearCacheAction,
8
9
  pruneSessionsAction,
9
10
  pruneTokensAction,
@@ -42,6 +43,27 @@ export function PruneSessionsForm({ prunable, copy }: { prunable: number; copy:
42
43
  )
43
44
  }
44
45
 
46
+ export function ApplyMigrationsForm({ copy }: { copy: Copy }) {
47
+ const [state, action] = useActionState(applyUpgradeAction, EMPTY_STATE)
48
+
49
+ return (
50
+ <form action={action} className="flex flex-col gap-2">
51
+ <FormError message={state.error} />
52
+ {state.notice === 'upgraded' && (
53
+ <Result>
54
+ {formatFromCopy(copy, 'adminPanel.system.upgradeApplied', {
55
+ core: state.values?.core ?? '0',
56
+ plugins: state.values?.plugins === '' ? '—' : (state.values?.plugins ?? '—'),
57
+ })}
58
+ </Result>
59
+ )}
60
+ <div>
61
+ <SubmitButton>{fromCopy(copy, 'adminPanel.system.applyMigrations')}</SubmitButton>
62
+ </div>
63
+ </form>
64
+ )
65
+ }
66
+
45
67
  export function PruneTokensForm({ copy }: { copy: Copy }) {
46
68
  const [state, action] = useActionState(pruneTokensAction, EMPTY_STATE)
47
69
 
@@ -6,12 +6,13 @@ import { NotFoundError, ValidationError } from '@meith/core'
6
6
  import { drivers } from '@meith/drivers'
7
7
  import { msg } from '@meith/i18n'
8
8
 
9
- import { recordAdminAction, requireAdmin } from './admin'
9
+ import { recordAdminAction, requireAdmin, requireFreshAdmin } from './admin'
10
10
  import type { FormState } from './auth-form-state'
11
11
  import { clearableTag } from './cache-targets'
12
12
  import { formStateReporter } from './form-state-reporter'
13
13
  import { requireSearch } from './search'
14
14
  import { requireMaintenance, requireRecount } from './system-admin'
15
+ import { applyPendingUpgrade } from './upgrade-notice'
15
16
 
16
17
  const SWEEP_LIMIT = 5_000
17
18
 
@@ -89,6 +90,28 @@ export async function reindexSearchAction(): Promise<FormState> {
89
90
  }
90
91
  }
91
92
 
93
+ export async function applyUpgradeAction(): Promise<FormState> {
94
+ try {
95
+ await requireFreshAdmin()
96
+
97
+ const result = await applyPendingUpgrade()
98
+
99
+ refreshSystemScreen()
100
+ revalidatePath('/admin')
101
+ await recordAdminAction({
102
+ action: 'system.upgrade_applied',
103
+ detail: { coreMigrations: result.coreMigrations, plugins: result.plugins },
104
+ })
105
+
106
+ return {
107
+ notice: 'upgraded',
108
+ values: { core: String(result.coreMigrations), plugins: result.plugins.join(', ') },
109
+ }
110
+ } catch (err) {
111
+ return toFormState(err)
112
+ }
113
+ }
114
+
92
115
  export async function clearCacheAction(_prev: FormState, form: FormData): Promise<FormState> {
93
116
  try {
94
117
  await requireAdmin()
@@ -1,12 +1,94 @@
1
1
  import 'server-only'
2
2
 
3
- import { env, logger } from '@meith/core'
4
- import { appliedPluginMigrations, getDb, readVersion } from '@meith/db'
3
+ import { env, logger, ValidationError } from '@meith/core'
4
+ import {
5
+ appliedPluginMigrations,
6
+ applyPluginMigration,
7
+ getDb,
8
+ PostgresNavigationRepository,
9
+ readVersion,
10
+ recordVersion,
11
+ runMigrations,
12
+ } from '@meith/db'
13
+ import { msg } from '@meith/i18n'
14
+ import { pluginNavigationPlacements } from '@meith/plugin-kit'
15
+ import { runPluginLifecycle } from '@meith/runtime'
5
16
  import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
6
17
 
7
18
  import { activeDefinitions } from './plugin-host'
8
19
 
9
- export const CODE_VERSION = '0.27.0'
20
+ export const CODE_VERSION = '0.28.0'
21
+
22
+ export interface UpgradeApplied {
23
+ readonly coreMigrations: number
24
+ readonly plugins: readonly string[]
25
+ }
26
+
27
+ export async function applyPendingUpgrade(): Promise<UpgradeApplied> {
28
+ const db = getDb()
29
+ const definitions = activeDefinitions()
30
+
31
+ const applied: Record<string, readonly string[]> = {}
32
+ for (const plugin of definitions) {
33
+ applied[plugin.key] = await appliedPluginMigrations(db, plugin.key)
34
+ }
35
+
36
+ const state: UpgradeState = {
37
+ recordedVersion: (await readVersion(db, 'core')) ?? CODE_VERSION,
38
+ codeVersion: CODE_VERSION,
39
+ pendingCoreMigrations: [],
40
+ plugins: definitions.map((plugin) => ({
41
+ key: plugin.key,
42
+ version: plugin.version,
43
+ dependsOn: plugin.dependsOn ?? [],
44
+ migrationIds: (plugin.migrations ?? []).map((migration) => migration.id),
45
+ })),
46
+ appliedPluginMigrations: applied,
47
+ }
48
+
49
+ const plan = planUpgrade(state)
50
+ if (plan.refusal !== null || plan.orderFailure !== null) {
51
+ const notice = upgradeNotice(plan, state)
52
+ throw notice === null
53
+ ? new ValidationError(msg('error.app.board-cannot-be-upgraded'))
54
+ : new ValidationError(notice)
55
+ }
56
+
57
+ const fresh = new Set<string>()
58
+ for (const plugin of definitions) {
59
+ if ((await readVersion(db, `plugin:${plugin.key}`)) === null) fresh.add(plugin.key)
60
+ }
61
+
62
+ const coreMigrations = await runMigrations()
63
+
64
+ const touched: string[] = []
65
+ for (const definition of definitions) {
66
+ let changed = false
67
+ for (const migration of definition.migrations ?? []) {
68
+ const ran = await applyPluginMigration(db, definition.key, migration.id, migration.statements)
69
+ if (ran) changed = true
70
+ }
71
+ if (fresh.has(definition.key) && definition.onInstall !== undefined) {
72
+ const { ran } = await runPluginLifecycle({ db, plugin: definition, phase: 'install' })
73
+ if (ran) changed = true
74
+ }
75
+ await recordVersion(db, `plugin:${definition.key}`, definition.version)
76
+ if (changed) touched.push(definition.key)
77
+ }
78
+
79
+ await new PostgresNavigationRepository(db).syncPluginItems(
80
+ pluginNavigationPlacements(definitions).map((item) => ({
81
+ key: item.key,
82
+ href: item.href,
83
+ audience: item.audience,
84
+ parentKey: item.parentKey,
85
+ })),
86
+ )
87
+
88
+ await recordVersion(db, 'core', CODE_VERSION)
89
+
90
+ return { coreMigrations, plugins: touched }
91
+ }
10
92
 
11
93
  export async function pendingUpgradeNotice(): Promise<string | null> {
12
94
  if (env.DATA_SOURCE !== 'postgres') return null
@@ -60,6 +60,7 @@ export function systemFormsCopy(t: Translator = untranslated()): Readonly<Record
60
60
  'adminPanel.system.jobIdHint',
61
61
  'adminPanel.system.retry',
62
62
  'adminPanel.system.nothingToIndex',
63
+ 'adminPanel.system.applyMigrations',
63
64
  ],
64
65
  t,
65
66
  ),
@@ -73,6 +74,7 @@ export function systemFormsCopy(t: Translator = untranslated()): Readonly<Record
73
74
  'adminPanel.system.indexFinished',
74
75
  'adminPanel.system.indexMore',
75
76
  'adminPanel.system.indexNext',
77
+ 'adminPanel.system.upgradeApplied',
76
78
  ],
77
79
  t,
78
80
  ),