kempo 4.8.0 → 4.9.1

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.
Files changed (251) hide show
  1. package/README.md +7 -3
  2. package/bin/cli.js +23 -1
  3. package/dist/admin/component-overrides.js +1 -1
  4. package/dist/admin/components/AddGroupControl.js +1 -1
  5. package/dist/admin/components/AdminTableControl.js +1 -1
  6. package/dist/admin/components/ClearExpiredSessionsControl.js +1 -1
  7. package/dist/admin/components/Dashboard.js +1 -1
  8. package/dist/admin/components/EmailTemplates.js +1 -1
  9. package/dist/admin/components/Emails.js +1 -1
  10. package/dist/admin/components/FragmentEditor.js +1 -1
  11. package/dist/admin/components/GlobalContentEditor.js +1 -1
  12. package/dist/admin/components/GroupDeleteSelected.js +1 -1
  13. package/dist/admin/components/GroupRemoveRecord.js +1 -1
  14. package/dist/admin/components/Menus.js +1 -1
  15. package/dist/admin/components/Monaco.js +1 -1
  16. package/dist/admin/components/PageEditor.js +1 -1
  17. package/dist/admin/components/TableRowDelete.js +1 -1
  18. package/dist/admin/components/TableRowLink.js +1 -1
  19. package/dist/admin/components/TemplateEditor.js +1 -1
  20. package/dist/admin/components/Updates.js +117 -0
  21. package/dist/admin/components/Updates.js.map +1 -0
  22. package/dist/admin/components/ValueEditor.js +1 -1
  23. package/dist/admin/components/dashboardLayout.js +1 -1
  24. package/dist/admin/components/settings/AdminSettings.js +1 -1
  25. package/dist/admin/components/settings/BooleanSetting.js +1 -1
  26. package/dist/admin/components/settings/JsonSetting.js +1 -1
  27. package/dist/admin/components/settings/NumberSetting.js +1 -1
  28. package/dist/admin/components/settings/SecretSetting.js +1 -1
  29. package/dist/admin/components/settings/Setting.js +1 -1
  30. package/dist/admin/components/settings/SettingEditor.js +1 -1
  31. package/dist/admin/components/settings/SettingsPanel.js +1 -1
  32. package/dist/admin/components/settings/StringSetting.js +1 -1
  33. package/dist/admin/components/settings/humanize.js +1 -1
  34. package/dist/admin/components/tableControls/AddMember.js +1 -1
  35. package/dist/admin/components/tableControls/AddPermission.js +1 -1
  36. package/dist/admin/components/tableControls/ExtensionDetails.js +1 -1
  37. package/dist/admin/components/tableControls/InstallExtension.js +1 -1
  38. package/dist/admin/components/tableControls/ToggleExtension.js +1 -1
  39. package/dist/admin/components/tableControls/UninstallExtension.js +1 -1
  40. package/dist/admin/components/widgets/Content.js +1 -1
  41. package/dist/admin/components/widgets/Extensions.js +1 -1
  42. package/dist/admin/components/widgets/Notifications.js +1 -1
  43. package/dist/admin/components/widgets/Pages.js +1 -1
  44. package/dist/admin/components/widgets/Users.js +1 -1
  45. package/dist/admin/init.js +1 -1
  46. package/dist/admin/nav.fragment.html +6 -0
  47. package/dist/admin/updates/index.page.html +14 -0
  48. package/dist/kempo/api/admin-globals/DELETE.js +1 -1
  49. package/dist/kempo/api/admin-globals/GET.js +1 -1
  50. package/dist/kempo/api/admin-globals/POST.js +1 -1
  51. package/dist/kempo/api/admin-globals/[id]/GET.js +1 -1
  52. package/dist/kempo/api/admin-globals/[id]/PUT.js +1 -1
  53. package/dist/kempo/api/admin-globals/[id]/disable/PUT.js +1 -1
  54. package/dist/kempo/api/admin-globals/[id]/enable/PUT.js +1 -1
  55. package/dist/kempo/api/auth/change-password/POST.js +1 -1
  56. package/dist/kempo/api/auth/login/email/POST.js +1 -1
  57. package/dist/kempo/api/auth/logout/POST.js +1 -1
  58. package/dist/kempo/api/auth/register/email/POST.js +1 -1
  59. package/dist/kempo/api/auth/reset-password/POST.js +1 -1
  60. package/dist/kempo/api/auth/session/GET.js +1 -1
  61. package/dist/kempo/api/auth/verify-email/POST.js +1 -1
  62. package/dist/kempo/api/dashboard/widgets/GET.js +1 -1
  63. package/dist/kempo/api/email-templates/GET.js +1 -1
  64. package/dist/kempo/api/email-templates/file/DELETE.js +1 -1
  65. package/dist/kempo/api/email-templates/file/GET.js +1 -1
  66. package/dist/kempo/api/email-templates/file/PUT.js +1 -1
  67. package/dist/kempo/api/email-templates/preview/GET.js +1 -1
  68. package/dist/kempo/api/emails/GET.js +1 -1
  69. package/dist/kempo/api/emails/[id]/GET.js +1 -1
  70. package/dist/kempo/api/emails/[id]/retry/POST.js +1 -1
  71. package/dist/kempo/api/emails/test/POST.js +1 -1
  72. package/dist/kempo/api/extensions/DELETE.js +1 -1
  73. package/dist/kempo/api/extensions/GET.js +1 -1
  74. package/dist/kempo/api/extensions/POST.js +1 -1
  75. package/dist/kempo/api/extensions/[name]/dependents/GET.js +1 -1
  76. package/dist/kempo/api/extensions/[name]/disable/POST.js +1 -1
  77. package/dist/kempo/api/extensions/[name]/enable/POST.js +1 -1
  78. package/dist/kempo/api/extensions/[name]/requirements/GET.js +1 -1
  79. package/dist/kempo/api/extensions/[name]/update/POST.js +1 -1
  80. package/dist/kempo/api/extensions/[name]/update-check/GET.js +1 -1
  81. package/dist/kempo/api/extensions/available/GET.js +1 -1
  82. package/dist/kempo/api/extensions/known/GET.js +1 -1
  83. package/dist/kempo/api/extensions/rating/PUT.js +1 -1
  84. package/dist/kempo/api/forgot-password/POST.js +1 -1
  85. package/dist/kempo/api/fragments/DELETE.js +1 -1
  86. package/dist/kempo/api/fragments/GET.js +1 -1
  87. package/dist/kempo/api/fragments/POST.js +1 -1
  88. package/dist/kempo/api/fragments/disable/PUT.js +1 -1
  89. package/dist/kempo/api/fragments/enable/PUT.js +1 -1
  90. package/dist/kempo/api/fragments/file/GET.js +1 -1
  91. package/dist/kempo/api/fragments/file/PATCH.js +1 -1
  92. package/dist/kempo/api/fragments/file/PUT.js +1 -1
  93. package/dist/kempo/api/fragments/lock/PUT.js +1 -1
  94. package/dist/kempo/api/globals/DELETE.js +1 -1
  95. package/dist/kempo/api/globals/GET.js +1 -1
  96. package/dist/kempo/api/globals/POST.js +1 -1
  97. package/dist/kempo/api/globals/disable/PUT.js +1 -1
  98. package/dist/kempo/api/globals/enable/PUT.js +1 -1
  99. package/dist/kempo/api/globals/entry/GET.js +1 -1
  100. package/dist/kempo/api/globals/entry/PUT.js +1 -1
  101. package/dist/kempo/api/globals/lock/PUT.js +1 -1
  102. package/dist/kempo/api/groups/DELETE.js +1 -1
  103. package/dist/kempo/api/groups/GET.js +1 -1
  104. package/dist/kempo/api/groups/PATCH.js +1 -1
  105. package/dist/kempo/api/groups/POST.js +1 -1
  106. package/dist/kempo/api/groups/[name]/GET.js +1 -1
  107. package/dist/kempo/api/groups/[name]/members/DELETE.js +1 -1
  108. package/dist/kempo/api/groups/[name]/members/GET.js +1 -1
  109. package/dist/kempo/api/groups/[name]/members/POST.js +1 -1
  110. package/dist/kempo/api/groups/[name]/permissions/DELETE.js +1 -1
  111. package/dist/kempo/api/groups/[name]/permissions/GET.js +1 -1
  112. package/dist/kempo/api/groups/[name]/permissions/POST.js +1 -1
  113. package/dist/kempo/api/menus/DELETE.js +1 -1
  114. package/dist/kempo/api/menus/GET.js +1 -1
  115. package/dist/kempo/api/menus/POST.js +1 -1
  116. package/dist/kempo/api/menus/adopt/PUT.js +1 -1
  117. package/dist/kempo/api/menus/items/DELETE.js +1 -1
  118. package/dist/kempo/api/menus/items/POST.js +1 -1
  119. package/dist/kempo/api/menus/items/enabled/PUT.js +1 -1
  120. package/dist/kempo/api/menus/items/order/PUT.js +1 -1
  121. package/dist/kempo/api/menus/location/PUT.js +1 -1
  122. package/dist/kempo/api/notifications/GET.js +1 -1
  123. package/dist/kempo/api/notifications/[id]/DELETE.js +1 -1
  124. package/dist/kempo/api/notifications/[id]/handled/POST.js +1 -1
  125. package/dist/kempo/api/notifications/[id]/read/POST.js +1 -1
  126. package/dist/kempo/api/notifications/count/GET.js +1 -1
  127. package/dist/kempo/api/notifications/read-all/POST.js +1 -1
  128. package/dist/kempo/api/pages/DELETE.js +1 -1
  129. package/dist/kempo/api/pages/GET.js +1 -1
  130. package/dist/kempo/api/pages/POST.js +1 -1
  131. package/dist/kempo/api/pages/directories/GET.js +1 -1
  132. package/dist/kempo/api/pages/disable/PUT.js +1 -1
  133. package/dist/kempo/api/pages/enable/PUT.js +1 -1
  134. package/dist/kempo/api/pages/file/GET.js +1 -1
  135. package/dist/kempo/api/pages/file/PATCH.js +1 -1
  136. package/dist/kempo/api/pages/file/PUT.js +1 -1
  137. package/dist/kempo/api/pages/lock/PUT.js +1 -1
  138. package/dist/kempo/api/pages/rescan/POST.js +1 -1
  139. package/dist/kempo/api/pages/search/GET.js +1 -1
  140. package/dist/kempo/api/pages/templates/GET.js +1 -1
  141. package/dist/kempo/api/permissions/DELETE.js +1 -1
  142. package/dist/kempo/api/permissions/GET.js +1 -1
  143. package/dist/kempo/api/permissions/PATCH.js +1 -1
  144. package/dist/kempo/api/permissions/POST.js +1 -1
  145. package/dist/kempo/api/realtime/WS.js +1 -1
  146. package/dist/kempo/api/realtime/connections/GET.js +1 -1
  147. package/dist/kempo/api/settings/[owner]/GET.js +1 -1
  148. package/dist/kempo/api/settings/[owner]/[name]/DELETE.js +1 -1
  149. package/dist/kempo/api/settings/[owner]/[name]/GET.js +1 -1
  150. package/dist/kempo/api/settings/[owner]/[name]/POST.js +1 -1
  151. package/dist/kempo/api/settings/public/GET.js +1 -1
  152. package/dist/kempo/api/templates/DELETE.js +1 -1
  153. package/dist/kempo/api/templates/GET.js +1 -1
  154. package/dist/kempo/api/templates/POST.js +1 -1
  155. package/dist/kempo/api/templates/disable/PUT.js +1 -1
  156. package/dist/kempo/api/templates/enable/PUT.js +1 -1
  157. package/dist/kempo/api/templates/file/GET.js +1 -1
  158. package/dist/kempo/api/templates/file/PATCH.js +1 -1
  159. package/dist/kempo/api/templates/file/PUT.js +1 -1
  160. package/dist/kempo/api/templates/lock/PUT.js +1 -1
  161. package/dist/kempo/api/updates/GET.js +2 -0
  162. package/dist/kempo/api/updates/GET.js.map +1 -0
  163. package/dist/kempo/api/updates/apply/POST.js +2 -0
  164. package/dist/kempo/api/updates/apply/POST.js.map +1 -0
  165. package/dist/kempo/api/updates/check/POST.js +2 -0
  166. package/dist/kempo/api/updates/check/POST.js.map +1 -0
  167. package/dist/kempo/api/updates/discard/POST.js +2 -0
  168. package/dist/kempo/api/updates/discard/POST.js.map +1 -0
  169. package/dist/kempo/api/updates/prepare/POST.js +2 -0
  170. package/dist/kempo/api/updates/prepare/POST.js.map +1 -0
  171. package/dist/kempo/api/updates/rollback/POST.js +2 -0
  172. package/dist/kempo/api/updates/rollback/POST.js.map +1 -0
  173. package/dist/kempo/api/user/DELETE.js +1 -1
  174. package/dist/kempo/api/user/GET.js +1 -1
  175. package/dist/kempo/api/user/PATCH.js +1 -1
  176. package/dist/kempo/api/user/[userid]/GET.js +1 -1
  177. package/dist/kempo/api/user/[userid]/groups/DELETE.js +1 -1
  178. package/dist/kempo/api/user/[userid]/groups/GET.js +1 -1
  179. package/dist/kempo/api/user/[userid]/groups/POST.js +1 -1
  180. package/dist/kempo/api/user/[userid]/name/GET.js +1 -1
  181. package/dist/kempo/api/user/[userid]/permissions/GET.js +1 -1
  182. package/dist/kempo/api/user/[userid]/permissions/all/GET.js +1 -1
  183. package/dist/kempo/api/user/[userid]/permissions/list/GET.js +1 -1
  184. package/dist/kempo/api/user/[userid]/permissions/some/GET.js +1 -1
  185. package/dist/kempo/api/user/[userid]/sessions/DELETE.js +1 -1
  186. package/dist/kempo/api/user/[userid]/sessions/GET.js +1 -1
  187. package/dist/kempo/api/user/[userid]/sessions/expired/DELETE.js +1 -1
  188. package/dist/kempo/api/user/create/POST.js +1 -1
  189. package/dist/kempo/api/user/search/GET.js +1 -1
  190. package/dist/kempo/components/AdminNavFooter.js +1 -1
  191. package/dist/kempo/components/DashboardWidget.js +1 -1
  192. package/dist/kempo/components/Login.js +1 -1
  193. package/dist/kempo/components/LogoutBtn.js +1 -1
  194. package/dist/kempo/components/Menu.js +1 -1
  195. package/dist/kempo/components/NotificationBell.js +1 -1
  196. package/dist/kempo/components/NotificationList.js +1 -1
  197. package/dist/kempo/components/Permission.js +1 -1
  198. package/dist/kempo/components/PermissionTarget.js +1 -1
  199. package/dist/kempo/components/Register.js +1 -1
  200. package/dist/kempo/components/Role.js +1 -1
  201. package/dist/kempo/components/UserCreatedAt.js +1 -1
  202. package/dist/kempo/components/UserDisplayName.js +1 -1
  203. package/dist/kempo/components/UserEmail.js +1 -1
  204. package/dist/kempo/components/UserEmailVerified.js +1 -1
  205. package/dist/kempo/components/UserName.js +1 -1
  206. package/dist/kempo/fetch.js +2 -2
  207. package/dist/kempo/fetch.js.map +1 -1
  208. package/dist/kempo/realtime.js +1 -1
  209. package/dist/kempo/sdk.js +2 -2
  210. package/dist/kempo/sdk.js.map +1 -1
  211. package/dist/kempo/utils/safeNext.js +1 -1
  212. package/docker/.dockerignore +9 -0
  213. package/docker/Dockerfile +25 -0
  214. package/docker/docker-compose.prod.yml +39 -0
  215. package/docker/entrypoint.mjs +96 -0
  216. package/middleware/kempo.js +53 -3
  217. package/package.json +3 -1
  218. package/server/db/schema.js +12 -0
  219. package/server/db/upgrades/README.md +20 -0
  220. package/server/sdk.js +3 -0
  221. package/server/utils/extensions/checkExtensionUpdate.js +9 -36
  222. package/server/utils/extensions/reconcileExtensions.js +90 -0
  223. package/server/utils/extensions/updateExtension.js +14 -153
  224. package/server/utils/extensions/upgradeExtension.js +117 -0
  225. package/server/utils/extensions/writeSiteFile.js +66 -0
  226. package/server/utils/settings/systemSettings.js +1 -0
  227. package/server/utils/setup/runUpgrades.js +70 -0
  228. package/server/utils/setup/seedDatabase.js +1 -1
  229. package/server/utils/setup/seedIfNew.js +21 -0
  230. package/server/utils/setup/syncCoreSchema.js +130 -0
  231. package/server/utils/setup/systemPermissions.js +2 -0
  232. package/server/utils/updates/applyStagedUpdate.js +95 -0
  233. package/server/utils/updates/applyUpdate.js +76 -0
  234. package/server/utils/updates/backupDatabase.js +35 -0
  235. package/server/utils/updates/discardUpdate.js +11 -0
  236. package/server/utils/updates/extensionTargets.js +101 -0
  237. package/server/utils/updates/fetchManifest.js +26 -0
  238. package/server/utils/updates/getExtensionUpdates.js +70 -0
  239. package/server/utils/updates/getInstallInfo.js +25 -0
  240. package/server/utils/updates/getUpdateStatus.js +57 -0
  241. package/server/utils/updates/helpers.js +30 -0
  242. package/server/utils/updates/notifySchemaAttention.js +35 -0
  243. package/server/utils/updates/npm.js +41 -0
  244. package/server/utils/updates/prepareUpdate.js +109 -0
  245. package/server/utils/updates/releaseStatus.js +23 -0
  246. package/server/utils/updates/restart.js +64 -0
  247. package/server/utils/updates/rollbackUpdate.js +56 -0
  248. package/server/utils/updates/runUpdateCheck.js +52 -0
  249. package/server/utils/updates/runtime.js +15 -0
  250. package/server/utils/updates/siteLayout.js +200 -0
  251. package/server/utils/updates/starterFiles.js +118 -0
@@ -13,9 +13,16 @@ import triggerHook from '../server/utils/hooks/triggerHook.js';
13
13
  import { ADMIN_GLOBALS_DIR } from '../server/utils/admin-global-content/helpers.js';
14
14
  import { startScheduler } from '../server/utils/scheduler/scheduler.js';
15
15
  import { ensureEncryptionKey } from '../server/utils/settings/ensureEncryptionKey.js';
16
+ import syncCoreSchema from '../server/utils/setup/syncCoreSchema.js';
17
+ import runUpgrades from '../server/utils/setup/runUpgrades.js';
18
+ import seedIfNew from '../server/utils/setup/seedIfNew.js';
19
+ import reconcileExtensions from '../server/utils/extensions/reconcileExtensions.js';
20
+ import { flagFailed, markHealthy } from '../server/utils/updates/siteLayout.js';
21
+ import notifySchemaAttention from '../server/utils/updates/notifySchemaAttention.js';
16
22
  import { ensureSystemSettings } from '../server/utils/settings/systemSettings.js';
17
23
  import { ensureSystemPermissions } from '../server/utils/setup/systemPermissions.js';
18
24
  import registerJobs from '../server/utils/scheduler/registerJobs.js';
25
+ import '../server/utils/updates/runtime.js';
19
26
  import { resolveAlias } from '../server/utils/extensions/aliases.js';
20
27
  import { normalizeScopes } from '../server/utils/extensions/scopes.js';
21
28
 
@@ -187,6 +194,11 @@ const serveDir = async (dirPath, method, request, response, resolveDir, rootDir,
187
194
  export default config => {
188
195
  startScheduler();
189
196
 
197
+ /* A site that runs from a Docker volume has just switched to new code: once it has stayed up a minute the switch is final. */
198
+ if(process.env.KEMPO_SITE_ROOT && process.env.KEMPO_DB !== 'pglite'){
199
+ setTimeout(() => markHealthy(process.env.KEMPO_SITE_ROOT), 60000).unref();
200
+ }
201
+
190
202
  /*
191
203
  The key that encrypts stored credentials exists before any request needs it, generated into .env the
192
204
  first time when the site has none. Opt out with `autoEncryptionKey: false` in the server config or
@@ -198,17 +210,55 @@ export default config => {
198
210
  if(error) console.error(`[kempo] ${error.msg}`);
199
211
  });
200
212
  /*
201
- What kempo added in a later version appears on an older site at start: its settings (so they show on the settings screen and can be
202
- edited), its permissions, and its own scheduled job (the email retries and cleanup). Only what is missing is added, and never in
213
+ What kempo added in a later version appears on an older site at start: its tables and columns (additive only, see syncCoreSchema), its settings (so they show on the settings screen and can be
214
+ edited), its permissions, and its own scheduled jobs (the email retries and cleanup, and the check for a newer kempo). Only what is missing is added, and never in
203
215
  the in-memory test database (nothing is set up there yet).
204
216
  */
205
217
  const systemSettingsReady = process.env.KEMPO_DB === 'pglite'
206
218
  ? Promise.resolve()
207
219
  : (async () => {
208
220
  for(const [step, run] of [
221
+ ['schema', async () => {
222
+ const [error, result] = await syncCoreSchema();
223
+ if(result?.applied.length) console.log(`[kempo] Database brought up to date: ${result.applied.join('; ')}`);
224
+ for(const { change, reason } of result?.failed ?? []) console.error(`[kempo] Database change failed: ${change}: ${reason}`);
225
+ return [error];
226
+ }],
227
+ ['seed', async () => {
228
+ const [error, result] = await seedIfNew();
229
+ if(result?.seeded) console.log('[kempo] New database: its groups, permissions and settings were set up. Open /setup to create the first administrator.');
230
+ return [error];
231
+ }],
232
+ ['upgrades', async () => {
233
+ const [error, result] = await runUpgrades();
234
+ if(result?.ran.length) console.log(`[kempo] Data migrations run: ${result.ran.join(', ')}`);
235
+ if(error){
236
+ console.error(`[kempo] ${error.msg}`);
237
+ if(error.breaking && process.env.KEMPO_SITE_ROOT){
238
+ flagFailed({ siteRoot: process.env.KEMPO_SITE_ROOT, reason: error.msg });
239
+ process.exit(1);
240
+ }
241
+ }
242
+ return [null];
243
+ }],
209
244
  ['settings', ensureSystemSettings],
210
245
  ['permissions', ensureSystemPermissions],
211
- ['jobs', () => registerJobs({ owner: 'kempo', jobs: [{ name: 'email-maintenance', every: '1m', callback: './server/utils/email/maintainEmails.js' }] })],
246
+ ['extensions', async () => {
247
+ const [error, result] = await reconcileExtensions();
248
+ for(const { name, from, to } of result?.upgraded ?? []) console.log(`[kempo] Extension ${name} brought up to date: ${from} -> ${to}`);
249
+ for(const { name, from, to } of result?.reverted ?? []) console.log(`[kempo] Extension ${name} is back on ${to} (was ${from})`);
250
+ for(const { name, reason } of result?.failed ?? []) console.error(`[kempo] Extension ${name} could not be brought up to date: ${reason}`);
251
+ if(result?.failed.some(item => item.major) && process.env.KEMPO_SITE_ROOT){
252
+ flagFailed({ siteRoot: process.env.KEMPO_SITE_ROOT, reason: result.failed.filter(item => item.major).map(item => `${item.name}: ${item.reason}`).join('; ') });
253
+ process.exit(1);
254
+ }
255
+ return [error];
256
+ }],
257
+ ['schema notice', notifySchemaAttention],
258
+ ['jobs', () => registerJobs({ owner: 'kempo', jobs: [
259
+ { name: 'email-maintenance', every: '1m', callback: './server/utils/email/maintainEmails.js' },
260
+ { name: 'update-check', every: '12h', callback: './server/utils/updates/runUpdateCheck.js' },
261
+ ] })],
212
262
  ]){
213
263
  const [error] = await run();
214
264
  if(error) console.error(`[kempo] System ${step}: ${error.msg}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo",
3
- "version": "4.8.0",
3
+ "version": "4.9.1",
4
4
  "type": "module",
5
5
  "description": "A fullstack CMS framework for creating websites with user authentication",
6
6
  "scripts": {
@@ -33,6 +33,8 @@
33
33
  "server/utils",
34
34
  "server/db/index.js",
35
35
  "server/db/schema.js",
36
+ "server/db/upgrades",
37
+ "docker",
36
38
  "server/sdk.js",
37
39
  "middleware/kempo.js",
38
40
  "app-templates",
@@ -115,6 +115,18 @@ export const extension = pgTable('extension', {
115
115
  updatedAt: timestamp('updatedAt').notNull(),
116
116
  });
117
117
 
118
+ /*
119
+ Which of kempo's own data migrations (server/db/upgrades/*.js) have run on this database. The additive changes to its
120
+ tables need no record, they are found by comparing; a migration that restructures data runs once and is recorded here.
121
+ The row with id 'baseline' marks the database as already holding what the shipped migrations produce.
122
+ */
123
+ export const upgrade = pgTable('upgrade', {
124
+ id: text('id').primaryKey(),
125
+ description: text('description'),
126
+ breaking: boolean('breaking').notNull().default(false),
127
+ appliedAt: timestamp('appliedAt').notNull().defaultNow(),
128
+ });
129
+
118
130
  /*
119
131
  Scheduled jobs: recurring work an extension declares in its kempo-config.json `jobs`. A row per job
120
132
  is the schedule and the lease: `nextRunAt` is when it is next due and `lockedUntil` is held while a
@@ -0,0 +1,20 @@
1
+ # Data migrations
2
+
3
+ Additive changes to kempo's tables (new tables, columns, indexes) need nothing here: they are applied when the server starts.
4
+ A change that restructures or moves data goes in a file in this folder, named so that they sort in the order they must run:
5
+
6
+ 4.9.0-split-user-name.js
7
+
8
+ ```js
9
+ export const meta = { description: 'Splits user.name into first and last', breaking: true };
10
+
11
+ export const up = async ({ db, sql }) => {
12
+ await db.execute(sql`...`);
13
+ };
14
+ ```
15
+
16
+ - It runs once, inside a transaction, when the first server of the new version starts, and is recorded in the `upgrade` table.
17
+ - `breaking: true` is for anything the previous version cannot run against (data moved or removed). The Updates screen warns
18
+ before such an update, kempo backs the database up first, and a failure puts the previous version and that backup back.
19
+ - Add one only in a release whose version is higher than the first one that has this folder: a site's `upgrade` table starts with
20
+ every file that was here when it first started recorded as already run.
package/server/sdk.js CHANGED
@@ -221,6 +221,9 @@ export { default as installExtension } from './utils/extensions/installExtension
221
221
  export { default as listExtensions } from './utils/extensions/listExtensions.js';
222
222
  export { default as uninstallExtension } from './utils/extensions/uninstallExtension.js';
223
223
  export { default as updateExtension } from './utils/extensions/updateExtension.js';
224
+ export { default as upgradeExtension } from './utils/extensions/upgradeExtension.js';
225
+ export { default as reconcileExtensions } from './utils/extensions/reconcileExtensions.js';
226
+ export { default as writeSiteFile } from './utils/extensions/writeSiteFile.js';
224
227
 
225
228
  /*
226
229
  Notifications
@@ -1,23 +1,11 @@
1
1
  import db from '../../db/index.js';
2
2
  import { extension } from '../../db/schema.js';
3
3
  import { eq } from 'drizzle-orm';
4
+ import { compareVersions, isVersion } from '../updates/helpers.js';
5
+ import fetchManifest from '../updates/fetchManifest.js';
4
6
 
5
- const toRawUrl = (gitUrl, file = 'package.json') => {
6
- const match = gitUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
7
- if(!match) return null;
8
- return `https://raw.githubusercontent.com/${match[1]}/${match[2]}/main/${file}`;
9
- };
10
-
11
- const semverGt = (a, b) => {
12
- const parse = v => (v || '0.0.0').replace(/^v/, '').split('.').map(Number);
13
- const [aMaj, aMin, aPatch] = parse(a);
14
- const [bMaj, bMin, bPatch] = parse(b);
15
- if(aMaj !== bMaj) return aMaj > bMaj;
16
- if(aMin !== bMin) return aMin > bMin;
17
- return aPatch > bPatch;
18
- };
19
-
20
- export default async ({ name }) => {
7
+ /* Whether npm has a newer version of one installed extension than the one recorded for it. */
8
+ export default async ({ name, fetcher }) => {
21
9
  if(!name){
22
10
  return [{ code: 400, msg: 'Extension name is required' }, null];
23
11
  }
@@ -32,25 +20,10 @@ export default async ({ name }) => {
32
20
  return [{ code: 500, msg: 'Failed to find extension' }, null];
33
21
  }
34
22
 
35
- const gitUrl = record.kempo?.git;
36
- if(!gitUrl){
37
- return [null, { hasUpdate: false, reason: 'No git URL configured' }];
38
- }
39
-
40
- const rawUrl = toRawUrl(gitUrl);
41
- if(!rawUrl){
42
- return [null, { hasUpdate: false, reason: 'Unsupported git URL format' }];
43
- }
44
-
45
- let remotePkg;
46
- try {
47
- const res = await fetch(rawUrl);
48
- if(!res.ok) return [null, { hasUpdate: false, reason: 'Failed to fetch remote package.json' }];
49
- remotePkg = await res.json();
50
- } catch(error){
51
- return [{ code: 502, msg: 'Failed to reach remote repository' }, null];
52
- }
23
+ const [error, manifest] = await fetchManifest({ name, fetcher });
24
+ if(error?.code === 404) return [null, { hasUpdate: false, reason: 'This extension is not on npm' }];
25
+ if(error) return [error, null];
53
26
 
54
- const hasUpdate = semverGt(remotePkg.version, record.version);
55
- return [null, { hasUpdate, currentVersion: record.version, latestVersion: remotePkg.version }];
27
+ const hasUpdate = isVersion(record.version) && compareVersions(manifest.version, record.version) > 0;
28
+ return [null, { hasUpdate, currentVersion: record.version, latestVersion: manifest.version }];
56
29
  };
@@ -0,0 +1,90 @@
1
+ import { and, eq } from 'drizzle-orm';
2
+ import { join } from 'path';
3
+ import db from '../../db/index.js';
4
+ import { extension } from '../../db/schema.js';
5
+ import readKempoConfig from './readKempoConfig.js';
6
+ import upgradeExtension from './upgradeExtension.js';
7
+ import { compareVersions, isBreakingBump, isVersion } from '../updates/helpers.js';
8
+
9
+ /*
10
+ Brings every installed extension's database in step with the version on disk. Run when the server starts, so it is the one
11
+ place an update of an extension takes effect, whichever way the new version got there (the Updates screen, a switch of the
12
+ site's code, npm run by hand, an image that was rebuilt). It is what a deploy script would otherwise have to do.
13
+
14
+ on disk is newer than the row upgradeExtension: its tables, update.js and whatever it newly declares
15
+ on disk is older than the row the site went back: the row says so, and no script runs, so going back is safe
16
+ the same, or not installed nothing
17
+
18
+ The row is claimed first (its version moved to the new one only if it still holds the old), so of several servers starting
19
+ together exactly one upgrades an extension. A failed upgrade gives the row its old version back, so it is tried again at
20
+ the next start, and it is reported rather than stopping the server.
21
+
22
+ Extensions are taken in dependency order (what each declares in `dependencies`, in the new version's kempo-config.json), so
23
+ an extension's update script finds the ones it builds on already up to date: kempo-media after kempo-files and kempo-thumbs,
24
+ kempo-commerce after kempo-products and kempo-payments.
25
+ */
26
+ let lastRun = { ran: false, upgraded: [], reverted: [], failed: [] };
27
+
28
+ export const getReconcileResult = () => lastRun;
29
+
30
+ const isBreaking = (from, to) => isVersion(from) && isVersion(to) && isBreakingBump(from, to);
31
+
32
+ const newer = (installed, recorded) => (isVersion(installed) && isVersion(recorded) ? compareVersions(installed, recorded) : (installed === recorded ? 0 : 1));
33
+
34
+ export default async ({ executor = db, root = process.cwd(), upgrade = upgradeExtension } = {}) => {
35
+ const result = { upgraded: [], reverted: [], failed: [] };
36
+
37
+ try {
38
+ const found = [];
39
+ for(const row of await executor.select().from(extension)){
40
+ const extRoot = join(root, 'node_modules', row.name);
41
+ try {
42
+ const { pkg, kempo } = await readKempoConfig(extRoot);
43
+ found.push({ row, extRoot, installed: pkg.version, requires: kempo.dependencies ?? row.kempo?.dependencies ?? [] });
44
+ } catch {
45
+ continue;
46
+ }
47
+ }
48
+
49
+ const ordered = [];
50
+ const placed = new Set();
51
+ const place = item => {
52
+ if(placed.has(item.row.name)) return;
53
+ placed.add(item.row.name);
54
+ for(const name of item.requires){
55
+ const dependency = found.find(other => other.row.name === name);
56
+ if(dependency) place(dependency);
57
+ }
58
+ ordered.push(item);
59
+ };
60
+ found.forEach(place);
61
+
62
+ for(const { row, extRoot, installed } of ordered){
63
+ if(!installed || installed === row.version) continue;
64
+
65
+ const direction = row.version ? newer(installed, row.version) : 1;
66
+ if(direction < 0){
67
+ await executor.update(extension).set({ version: installed, updatedAt: new Date() }).where(eq(extension.name, row.name));
68
+ result.reverted.push({ name: row.name, from: row.version, to: installed });
69
+ continue;
70
+ }
71
+
72
+ const claim = and(eq(extension.name, row.name), row.version ? eq(extension.version, row.version) : undefined);
73
+ const [claimed] = await executor.update(extension).set({ version: installed }).where(claim).returning({ name: extension.name });
74
+ if(!claimed) continue;
75
+
76
+ const [error] = await upgrade({ name: row.name, record: row, extRoot });
77
+ if(error){
78
+ await executor.update(extension).set({ version: row.version }).where(eq(extension.name, row.name));
79
+ result.failed.push({ name: row.name, from: row.version, to: installed, major: isBreaking(row.version, installed), reason: error.msg });
80
+ } else {
81
+ result.upgraded.push({ name: row.name, from: row.version, to: installed });
82
+ }
83
+ }
84
+ } catch(error) {
85
+ return [{ code: 500, msg: `The extensions could not be brought up to date: ${String(error.cause?.message || error.message).slice(0, 300)}` }, result];
86
+ }
87
+
88
+ lastRun = { ran: true, ...result };
89
+ return [null, result];
90
+ };
@@ -1,29 +1,29 @@
1
1
  import db from '../../db/index.js';
2
- import { extension, hook, permission, group, groupPermission } from '../../db/schema.js';
3
- import { eq, and } from 'drizzle-orm';
2
+ import { extension } from '../../db/schema.js';
3
+ import { eq } from 'drizzle-orm';
4
4
  import { createRequire } from 'module';
5
5
  import { dirname, join } from 'path';
6
- import { randomUUID } from 'crypto';
7
6
  import { execSync } from 'child_process';
8
- import triggerHook, { clearHandlerCache } from '../hooks/triggerHook.js';
9
- import createHook from '../hooks/createHook.js';
10
- import registerDashboardWidget from '../dashboard/registerDashboardWidget.js';
11
- import unregisterDashboardWidget from '../dashboard/unregisterDashboardWidget.js';
12
- import registerJobs from '../scheduler/registerJobs.js';
13
- import setSetting from '../settings/setSetting.js';
14
- import declaredValue from '../settings/declaredValue.js';
15
- import createTablesFromSchema from './createTablesFromSchema.js';
16
7
  import readKempoConfig from './readKempoConfig.js';
8
+ import upgradeExtension from './upgradeExtension.js';
17
9
  import { normalizeAliases } from './aliases.js';
18
10
  import { normalizeScopes, scopeNames } from './scopes.js';
19
11
  import checkClaims from './checkClaims.js';
20
12
 
21
13
  const requireFromProject = createRequire(join(process.cwd(), 'package.json'));
22
14
 
15
+ /*
16
+ Installs a newer version of one extension in place, and brings the database in step with it straight away, without a
17
+ restart (see upgradeExtension). For a site that runs from a Docker volume updates go through the Updates screen instead,
18
+ which stages them, shows what they change and can go back; this refuses there so it cannot bypass that.
19
+ */
23
20
  export default async ({ name, version = 'latest' }) => {
24
21
  if(!name){
25
22
  return [{ code: 400, msg: 'Extension name is required' }, null];
26
23
  }
24
+ if(process.env.KEMPO_SITE_ROOT){
25
+ return [{ code: 409, msg: 'This site updates extensions from the Updates screen' }, null];
26
+ }
27
27
 
28
28
  let oldRecord;
29
29
  try {
@@ -35,27 +35,18 @@ export default async ({ name, version = 'latest' }) => {
35
35
  return [{ code: 500, msg: 'Failed to find extension' }, null];
36
36
  }
37
37
 
38
- const oldKempo = oldRecord.kempo || {};
39
38
  const oldVersion = oldRecord.version;
40
39
 
41
- /*
42
- Install updated package
43
- */
44
40
  try {
45
41
  execSync(`npm install ${name}@${version}`, { cwd: process.cwd(), stdio: 'pipe' });
46
42
  } catch(error){
47
43
  return [{ code: 500, msg: `Failed to update package: ${error.message}` }, null];
48
44
  }
49
45
 
50
- /*
51
- What the new version declares comes from its kempo-config.json (package.json's own `kempo` field is
52
- only the fallback for an older extension). Reading package.json alone meant that no extension's new
53
- permissions, settings, groups, hooks or widgets ever reached the database through this update.
54
- */
55
- let extRoot, pkg, newKempo;
46
+ let extRoot, newKempo;
56
47
  try {
57
48
  extRoot = dirname(requireFromProject.resolve(`${name}/package.json`));
58
- ({ pkg, kempo: newKempo } = await readKempoConfig(extRoot));
49
+ ({ kempo: newKempo } = await readKempoConfig(extRoot));
59
50
  } catch {
60
51
  return [{ code: 404, msg: `Updated package "${name}" not found` }, null];
61
52
  }
@@ -81,135 +72,5 @@ export default async ({ name, version = 'latest' }) => {
81
72
  return [{ ...refusal, msg }, null];
82
73
  }
83
74
 
84
- /*
85
- Create any new tables from schema
86
- */
87
- if(newKempo.schema){
88
- try {
89
- const schemaModule = await import(`${join(extRoot, newKempo.schema)}?t=${Date.now()}`);
90
- const [err] = await createTablesFromSchema(schemaModule);
91
- if(err) return [err, null];
92
- } catch(error){
93
- return [{ code: 500, msg: `Failed to load extension schema: ${error.message}` }, null];
94
- }
95
- }
96
-
97
- /*
98
- Run update script with diff context
99
- */
100
- try {
101
- const updateModule = await import(`${join(extRoot, 'update.js')}?t=${Date.now()}`).catch(() => null);
102
- if(updateModule?.default){
103
- await updateModule.default({ oldVersion, newVersion: pkg.version, oldKempo, newKempo });
104
- }
105
- } catch(error){
106
- return [{ code: 500, msg: `Extension update script failed: ${error.message}` }, null];
107
- }
108
-
109
- /*
110
- Add new permissions (never delete — user data may depend on them)
111
- */
112
- const oldPermNames = new Set((oldKempo.permissions || []).map(p => p.name));
113
- for(const { name: permName, description } of newKempo.permissions || []){
114
- if(!oldPermNames.has(permName)){
115
- await db.insert(permission).values({
116
- name: permName,
117
- description: description || null,
118
- owner: name,
119
- createdAt: new Date(),
120
- }).onConflictDoNothing();
121
- }
122
- }
123
-
124
- /*
125
- Add new settings only (never overwrite — user may have changed the value)
126
- */
127
- const oldSettingNames = new Set((oldKempo.settings || []).map(s => s.name));
128
- for(const { name: settingName, value, type, description } of newKempo.settings || []){
129
- if(!oldSettingNames.has(settingName)){
130
- await setSetting(name, settingName, declaredValue(value, type), type || null, false, description || null);
131
- }
132
- }
133
-
134
- /*
135
- Add new groups
136
- */
137
- const oldGroupNames = new Set((oldKempo.groups || []).map(g => g.name));
138
- for(const { name: groupName, description, permissions: groupPerms = [] } of newKempo.groups || []){
139
- if(!oldGroupNames.has(groupName)){
140
- await db.insert(group).values({
141
- name: groupName,
142
- description: description || null,
143
- owner: name,
144
- createdAt: new Date(),
145
- }).onConflictDoNothing();
146
- for(const permName of groupPerms){
147
- await db.insert(groupPermission).values({
148
- id: randomUUID(),
149
- groupName,
150
- permissionName: permName,
151
- createdAt: new Date(),
152
- }).onConflictDoNothing();
153
- }
154
- }
155
- }
156
-
157
- /*
158
- Sync hooks: add new, remove deleted
159
- */
160
- const oldHooks = oldKempo.hooks || {};
161
- const newHooks = newKempo.hooks || {};
162
- for(const [event, callback] of Object.entries(newHooks)){
163
- if(!oldHooks[event]){
164
- await createHook({ owner: name, event, callback });
165
- }
166
- }
167
- for(const event of Object.keys(oldHooks)){
168
- if(!newHooks[event]){
169
- await db.delete(hook).where(and(eq(hook.owner, name), eq(hook.event, event))).catch(() => {});
170
- }
171
- }
172
-
173
- /*
174
- Sync dashboard widgets: register or refresh every declared one, remove those no longer declared
175
- */
176
- const newWidgetKeys = new Set((newKempo.widgets || []).map(w => w.key));
177
- for(const widget of newKempo.widgets || []){
178
- const [widgetError] = await registerDashboardWidget({ ...widget, owner: name });
179
- if(widgetError){
180
- console.warn(`[${name}] dashboard widget "${widget.key}" was not registered: ${widgetError.msg}`);
181
- }
182
- }
183
- for(const { key } of oldKempo.widgets || []){
184
- if(!newWidgetKeys.has(key)){
185
- await unregisterDashboardWidget({ owner: name, key });
186
- }
187
- }
188
-
189
- /*
190
- Sync scheduled jobs: the schedule becomes exactly what is declared now
191
- */
192
- if((newKempo.jobs || []).length || (oldKempo.jobs || []).length){
193
- const [jobsError] = await registerJobs({ owner: name, jobs: newKempo.jobs || [] });
194
- if(jobsError){
195
- console.warn(`[${name}] scheduled jobs were not registered: ${jobsError.msg}`);
196
- }
197
- }
198
-
199
- const now = new Date();
200
- try {
201
- await db.update(extension).set({
202
- version: pkg.version || null,
203
- kempo: newKempo,
204
- updatedAt: now,
205
- }).where(eq(extension.name, name));
206
- } catch(error){
207
- return [{ code: 500, msg: 'Failed to update extension record' }, null];
208
- }
209
-
210
- clearHandlerCache();
211
- await triggerHook('extension:updated', { name, oldVersion, newVersion: pkg.version });
212
-
213
- return [null, { name, oldVersion, newVersion: pkg.version }];
75
+ return upgradeExtension({ name, record: oldRecord, extRoot });
214
76
  };
215
-
@@ -0,0 +1,117 @@
1
+ import db from '../../db/index.js';
2
+ import { extension, hook, permission, group, groupPermission } from '../../db/schema.js';
3
+ import { eq, and } from 'drizzle-orm';
4
+ import { join } from 'path';
5
+ import { pathToFileURL } from 'url';
6
+ import { randomUUID } from 'crypto';
7
+ import triggerHook, { clearHandlerCache } from '../hooks/triggerHook.js';
8
+ import createHook from '../hooks/createHook.js';
9
+ import registerDashboardWidget from '../dashboard/registerDashboardWidget.js';
10
+ import unregisterDashboardWidget from '../dashboard/unregisterDashboardWidget.js';
11
+ import registerJobs from '../scheduler/registerJobs.js';
12
+ import setSetting from '../settings/setSetting.js';
13
+ import declaredValue from '../settings/declaredValue.js';
14
+ import syncCoreSchema from '../setup/syncCoreSchema.js';
15
+ import readKempoConfig from './readKempoConfig.js';
16
+ import { invalidateScopeCache } from './scopeCache.js';
17
+
18
+ /*
19
+ Brings the database in step with a newer version of an extension that is already on disk: its tables (additions only: new
20
+ tables, columns and indexes), its own `update.js` (given the old and new version and what each declared), and whatever the
21
+ new version declares that the old one did not (permissions, settings, groups, hooks, dashboard widgets and scheduled jobs).
22
+ It only adds: a permission, setting or group an extension no longer declares stays, because data may depend on it, and a
23
+ setting the owner changed keeps their value. Hooks, widgets and jobs follow what is declared now.
24
+
25
+ `record` is the extension's row as it was before. Resolves to [error, { name, oldVersion, newVersion }]. Run by the startup
26
+ step (reconcileExtensions) and by updateExtension, never on a version that is older than the row's.
27
+ */
28
+ export default async ({ name, record, extRoot = join(process.cwd(), 'node_modules', name) }) => {
29
+ const oldKempo = record.kempo || {};
30
+ const oldVersion = record.version;
31
+
32
+ let pkg, newKempo;
33
+ try {
34
+ ({ pkg, kempo: newKempo } = await readKempoConfig(extRoot));
35
+ } catch {
36
+ return [{ code: 404, msg: `Package "${name}" is not installed` }, null];
37
+ }
38
+
39
+ if(newKempo.schema){
40
+ try {
41
+ const schemaModule = await import(`${pathToFileURL(join(extRoot, newKempo.schema)).href}?t=${Date.now()}`);
42
+ const [schemaError, synced] = await syncCoreSchema({ schema: schemaModule });
43
+ if(schemaError) return [schemaError, null];
44
+ for(const { change, reason } of synced.failed) console.warn(`[${name}] database change not applied: ${change}: ${reason}`);
45
+ } catch(error) {
46
+ return [{ code: 500, msg: `Failed to load extension schema: ${error.message}` }, null];
47
+ }
48
+ }
49
+
50
+ try {
51
+ const updateModule = await import(`${pathToFileURL(join(extRoot, 'update.js')).href}?t=${Date.now()}`).catch(() => null);
52
+ if(updateModule?.default){
53
+ await updateModule.default({ oldVersion, newVersion: pkg.version, oldKempo, newKempo });
54
+ }
55
+ } catch(error) {
56
+ return [{ code: 500, msg: `Extension update script failed: ${error.message}` }, null];
57
+ }
58
+
59
+ const oldPermNames = new Set((oldKempo.permissions || []).map(p => p.name));
60
+ for(const { name: permName, description } of newKempo.permissions || []){
61
+ if(!oldPermNames.has(permName)){
62
+ await db.insert(permission).values({ name: permName, description: description || null, owner: name, createdAt: new Date() }).onConflictDoNothing();
63
+ }
64
+ }
65
+
66
+ const oldSettingNames = new Set((oldKempo.settings || []).map(s => s.name));
67
+ for(const { name: settingName, value, type, description } of newKempo.settings || []){
68
+ if(!oldSettingNames.has(settingName)){
69
+ await setSetting(name, settingName, declaredValue(value, type), type || null, false, description || null);
70
+ }
71
+ }
72
+
73
+ const oldGroupNames = new Set((oldKempo.groups || []).map(g => g.name));
74
+ for(const { name: groupName, description, permissions: groupPerms = [] } of newKempo.groups || []){
75
+ if(!oldGroupNames.has(groupName)){
76
+ await db.insert(group).values({ name: groupName, description: description || null, owner: name, createdAt: new Date() }).onConflictDoNothing();
77
+ for(const permName of groupPerms){
78
+ await db.insert(groupPermission).values({ id: randomUUID(), groupName, permissionName: permName, createdAt: new Date() }).onConflictDoNothing();
79
+ }
80
+ }
81
+ }
82
+
83
+ const oldHooks = oldKempo.hooks || {};
84
+ const newHooks = newKempo.hooks || {};
85
+ for(const [event, callback] of Object.entries(newHooks)){
86
+ if(!oldHooks[event]) await createHook({ owner: name, event, callback });
87
+ }
88
+ for(const event of Object.keys(oldHooks)){
89
+ if(!newHooks[event]) await db.delete(hook).where(and(eq(hook.owner, name), eq(hook.event, event))).catch(() => {});
90
+ }
91
+
92
+ const newWidgetKeys = new Set((newKempo.widgets || []).map(w => w.key));
93
+ for(const widget of newKempo.widgets || []){
94
+ const [widgetError] = await registerDashboardWidget({ ...widget, owner: name });
95
+ if(widgetError) console.warn(`[${name}] dashboard widget "${widget.key}" was not registered: ${widgetError.msg}`);
96
+ }
97
+ for(const { key } of oldKempo.widgets || []){
98
+ if(!newWidgetKeys.has(key)) await unregisterDashboardWidget({ owner: name, key });
99
+ }
100
+
101
+ if((newKempo.jobs || []).length || (oldKempo.jobs || []).length){
102
+ const [jobsError] = await registerJobs({ owner: name, jobs: newKempo.jobs || [] });
103
+ if(jobsError) console.warn(`[${name}] scheduled jobs were not registered: ${jobsError.msg}`);
104
+ }
105
+
106
+ try {
107
+ await db.update(extension).set({ version: pkg.version || null, kempo: newKempo, updatedAt: new Date() }).where(eq(extension.name, name));
108
+ } catch {
109
+ return [{ code: 500, msg: 'Failed to update extension record' }, null];
110
+ }
111
+
112
+ clearHandlerCache();
113
+ invalidateScopeCache();
114
+ await triggerHook('extension:updated', { name, oldVersion, newVersion: pkg.version });
115
+
116
+ return [null, { name, oldVersion, newVersion: pkg.version }];
117
+ };