kempo 4.8.0 → 4.9.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.
Files changed (62) hide show
  1. package/README.md +7 -3
  2. package/bin/cli.js +23 -1
  3. package/dist/admin/components/Updates.js +117 -0
  4. package/dist/admin/components/Updates.js.map +1 -0
  5. package/dist/admin/nav.fragment.html +6 -0
  6. package/dist/admin/updates/index.page.html +14 -0
  7. package/dist/kempo/api/updates/GET.js +2 -0
  8. package/dist/kempo/api/updates/GET.js.map +1 -0
  9. package/dist/kempo/api/updates/apply/POST.js +2 -0
  10. package/dist/kempo/api/updates/apply/POST.js.map +1 -0
  11. package/dist/kempo/api/updates/check/POST.js +2 -0
  12. package/dist/kempo/api/updates/check/POST.js.map +1 -0
  13. package/dist/kempo/api/updates/discard/POST.js +2 -0
  14. package/dist/kempo/api/updates/discard/POST.js.map +1 -0
  15. package/dist/kempo/api/updates/prepare/POST.js +2 -0
  16. package/dist/kempo/api/updates/prepare/POST.js.map +1 -0
  17. package/dist/kempo/api/updates/rollback/POST.js +2 -0
  18. package/dist/kempo/api/updates/rollback/POST.js.map +1 -0
  19. package/dist/kempo/fetch.js +1 -1
  20. package/dist/kempo/fetch.js.map +1 -1
  21. package/dist/kempo/sdk.js +1 -1
  22. package/dist/kempo/sdk.js.map +1 -1
  23. package/docker/.dockerignore +9 -0
  24. package/docker/Dockerfile +25 -0
  25. package/docker/docker-compose.prod.yml +39 -0
  26. package/docker/entrypoint.mjs +96 -0
  27. package/middleware/kempo.js +53 -3
  28. package/package.json +3 -1
  29. package/server/db/schema.js +12 -0
  30. package/server/db/upgrades/README.md +20 -0
  31. package/server/sdk.js +3 -0
  32. package/server/utils/extensions/checkExtensionUpdate.js +9 -36
  33. package/server/utils/extensions/reconcileExtensions.js +90 -0
  34. package/server/utils/extensions/updateExtension.js +14 -153
  35. package/server/utils/extensions/upgradeExtension.js +117 -0
  36. package/server/utils/extensions/writeSiteFile.js +66 -0
  37. package/server/utils/settings/systemSettings.js +1 -0
  38. package/server/utils/setup/runUpgrades.js +70 -0
  39. package/server/utils/setup/seedDatabase.js +1 -1
  40. package/server/utils/setup/seedIfNew.js +21 -0
  41. package/server/utils/setup/syncCoreSchema.js +130 -0
  42. package/server/utils/setup/systemPermissions.js +2 -0
  43. package/server/utils/updates/applyStagedUpdate.js +95 -0
  44. package/server/utils/updates/applyUpdate.js +76 -0
  45. package/server/utils/updates/backupDatabase.js +35 -0
  46. package/server/utils/updates/discardUpdate.js +11 -0
  47. package/server/utils/updates/extensionTargets.js +101 -0
  48. package/server/utils/updates/fetchManifest.js +26 -0
  49. package/server/utils/updates/getExtensionUpdates.js +70 -0
  50. package/server/utils/updates/getInstallInfo.js +25 -0
  51. package/server/utils/updates/getUpdateStatus.js +57 -0
  52. package/server/utils/updates/helpers.js +30 -0
  53. package/server/utils/updates/notifySchemaAttention.js +35 -0
  54. package/server/utils/updates/npm.js +41 -0
  55. package/server/utils/updates/prepareUpdate.js +109 -0
  56. package/server/utils/updates/releaseStatus.js +23 -0
  57. package/server/utils/updates/restart.js +64 -0
  58. package/server/utils/updates/rollbackUpdate.js +56 -0
  59. package/server/utils/updates/runUpdateCheck.js +52 -0
  60. package/server/utils/updates/runtime.js +15 -0
  61. package/server/utils/updates/siteLayout.js +200 -0
  62. package/server/utils/updates/starterFiles.js +118 -0
@@ -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
+ };
@@ -0,0 +1,66 @@
1
+ import { createHash } from 'crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
3
+ import { dirname, join, normalize, sep } from 'path';
4
+
5
+ /*
6
+ For an extension that has to put a file into the site (a generated template, a starter page): writes it the way kempo's own
7
+ starter files are handled, so an update of the extension never overwrites what the owner changed.
8
+
9
+ not there, never written by this extension written
10
+ not there, but this extension wrote it before the owner deleted it, so it stays deleted
11
+ identical to what is being written nothing to do
12
+ still exactly what this extension last wrote never edited, so replaced
13
+ anything else edited by the owner (or anyone), so kept
14
+
15
+ The extension's fingerprints are in .kempo/shipped/<owner>.json. `path` is relative to the site and must be inside public/
16
+ or templates/. Most extensions need none of this, because their own public/ is served from the package; this is for the
17
+ few that generate something into the site. Resolves to [error, { status }] where status is "written", "current", "kept"
18
+ or "deleted".
19
+ */
20
+ const hashOf = content => createHash('sha256').update(content).digest('hex');
21
+
22
+ export default ({ owner, path, content, cwd = process.cwd() } = {}) => {
23
+ if(typeof owner !== 'string' || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(owner)){
24
+ return [{ code: 400, msg: 'owner must be the extension name' }, null];
25
+ }
26
+ const relative = typeof path === 'string' ? normalize(path).split(sep).join('/') : '';
27
+ if(!relative || relative.startsWith('..') || relative.startsWith('/') || !/^(public|templates)\//.test(relative)){
28
+ return [{ code: 400, msg: 'path must be a file inside public/ or templates/' }, null];
29
+ }
30
+ if(typeof content !== 'string' && !Buffer.isBuffer(content)){
31
+ return [{ code: 400, msg: 'content must be text or a buffer' }, null];
32
+ }
33
+
34
+ const recordFile = join(cwd, '.kempo', 'shipped', `${owner.replace('/', '__')}.json`);
35
+ let files = {};
36
+ try {
37
+ files = JSON.parse(readFileSync(recordFile, 'utf8')).files ?? {};
38
+ } catch {
39
+ files = {};
40
+ }
41
+ const save = () => {
42
+ mkdirSync(dirname(recordFile), { recursive: true });
43
+ writeFileSync(recordFile, JSON.stringify({ files }, null, 2));
44
+ };
45
+
46
+ const target = join(cwd, relative);
47
+ const wanted = hashOf(content);
48
+
49
+ if(!existsSync(target)){
50
+ if(files[relative]) return [null, { status: 'deleted' }];
51
+ } else {
52
+ const here = hashOf(readFileSync(target));
53
+ if(here === wanted){
54
+ files[relative] = wanted;
55
+ save();
56
+ return [null, { status: 'current' }];
57
+ }
58
+ if(files[relative] !== here) return [null, { status: 'kept' }];
59
+ }
60
+
61
+ mkdirSync(dirname(target), { recursive: true });
62
+ writeFileSync(target, content);
63
+ files[relative] = wanted;
64
+ save();
65
+ return [null, { status: 'written' }];
66
+ };
@@ -20,6 +20,7 @@ export const SYSTEM_SETTINGS = [
20
20
  { owner: 'system', name: 'email_retention_days', value: 30, type: 'number', isPublic: false, description: 'Days to keep the log of sent emails before it is deleted (0 keeps it forever)' },
21
21
  { owner: 'system', name: 'email_retention_by_type', value: {}, type: 'json', isPublic: false, description: 'Days to keep a particular kind of email, instead of the number above, as { "verify-email": 7, "kempo-commerce/order-processing": 365 } (0 keeps that kind forever)' },
22
22
  { owner: 'system', name: 'email_from', value: '', type: 'string', isPublic: false, description: 'The address emails are sent from, as name@example.com or Shop Name <name@example.com>. It must be on a domain verified with the email provider. Empty uses EMAIL_FROM from the server environment' },
23
+ { owner: 'system', name: 'update_check_enabled', value: true, type: 'boolean', isPublic: false, description: 'Check npm every 12 hours for a newer kempo and tell the people who can install it' },
23
24
  ];
24
25
 
25
26
  /* Resolves to [error, { added: [names] }]. */
@@ -0,0 +1,70 @@
1
+ import { existsSync, readdirSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { fileURLToPath, pathToFileURL } from 'url';
4
+ import { sql } from 'drizzle-orm';
5
+ import db from '../../db/index.js';
6
+ import { upgrade } from '../../db/schema.js';
7
+
8
+ /*
9
+ Kempo's own data migrations: the files in server/db/upgrades, which restructure or move data that the additive sync of
10
+ the tables (syncCoreSchema) cannot. See that folder's README for how to write one.
11
+
12
+ They run in file-name order, each once, in a transaction, when the server starts and after the tables are in step. A
13
+ database that has never had the `upgrade` table is taken to already hold what the shipped migrations make (a new site,
14
+ or a site on its first version with this): they are recorded as run without being run, under a row called 'baseline'.
15
+ */
16
+ export const DEFAULT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'db', 'upgrades');
17
+
18
+ const filesIn = dir => (existsSync(dir) ? readdirSync(dir).filter(name => name.endsWith('.js')).sort() : []);
19
+
20
+ const load = (dir, file) => import(`${pathToFileURL(join(dir, file)).href}?t=${Date.now()}`);
21
+
22
+ const appliedIds = async executor => new Set((await executor.select({ id: upgrade.id }).from(upgrade)).map(row => row.id));
23
+
24
+ /*
25
+ What would run, in order, as [{ id, description, breaking }]. `dir` is another version's folder, to look ahead at an
26
+ update before it is switched to. Nothing is run or recorded.
27
+ */
28
+ export const pendingUpgrades = async ({ dir = DEFAULT_DIR, executor = db } = {}) => {
29
+ const applied = await appliedIds(executor);
30
+ if(!applied.has('baseline')) return [];
31
+ const pending = [];
32
+ for(const file of filesIn(dir).filter(name => !applied.has(name))){
33
+ const { meta = {} } = await load(dir, file);
34
+ pending.push({ id: file, description: meta.description ?? '', breaking: Boolean(meta.breaking) });
35
+ }
36
+ return pending;
37
+ };
38
+
39
+ /*
40
+ Resolves to [error, { ran }]. An error is the first migration that failed, with `breaking` saying whether it was one the
41
+ previous version cannot run without; nothing after it runs, and what it did is rolled back.
42
+ */
43
+ export default async ({ dir = DEFAULT_DIR, executor = db } = {}) => {
44
+ try {
45
+ const applied = await appliedIds(executor);
46
+ if(!applied.has('baseline')){
47
+ await executor.transaction(async tx => {
48
+ for(const id of [...filesIn(dir), 'baseline']) await tx.insert(upgrade).values({ id, description: id === 'baseline' ? 'Existing migrations were already part of this database' : 'Part of the baseline' }).onConflictDoNothing();
49
+ });
50
+ return [null, { ran: [] }];
51
+ }
52
+
53
+ const ran = [];
54
+ for(const file of filesIn(dir).filter(name => !applied.has(name))){
55
+ const { meta = {}, up } = await load(dir, file);
56
+ try {
57
+ await executor.transaction(async tx => {
58
+ await up({ db: tx, sql });
59
+ await tx.insert(upgrade).values({ id: file, description: meta.description ?? null, breaking: Boolean(meta.breaking) });
60
+ });
61
+ ran.push(file);
62
+ } catch(error) {
63
+ return [{ code: 500, msg: `The data migration ${file} failed: ${String(error.cause?.message || error.message).slice(0, 300)}`, breaking: Boolean(meta.breaking), id: file }, { ran }];
64
+ }
65
+ }
66
+ return [null, { ran }];
67
+ } catch(error) {
68
+ return [{ code: 500, msg: `The data migrations could not be checked: ${String(error.cause?.message || error.message).slice(0, 300)}`, breaking: false }, null];
69
+ }
70
+ };
@@ -22,7 +22,7 @@ const DEFAULT_GROUPS = {
22
22
  'system:fragments:read', 'system:fragments:create', 'system:fragments:update', 'system:fragments:delete',
23
23
  'system:globals:read', 'system:globals:create', 'system:globals:update', 'system:globals:delete',
24
24
  'system:extensions:read', 'system:extensions:install', 'system:extensions:uninstall', 'system:extensions:manage',
25
- 'system:realtime:read', 'system:emails:read', 'system:emails:manage'
25
+ 'system:realtime:read', 'system:emails:read', 'system:emails:manage', 'system:updates:manage'
26
26
  ]
27
27
  }
28
28
  };
@@ -0,0 +1,21 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import db from '../../db/index.js';
3
+ import { group } from '../../db/schema.js';
4
+ import seedDatabase from './seedDatabase.js';
5
+
6
+ /*
7
+ A database with no Administrators group is a new one (the group cannot be deleted), so kempo's groups, permissions and
8
+ settings are put in it, which is what `scripts/init-db.js` does by hand. It is what lets a new site start from an empty
9
+ database with no command typed: the tables come from syncCoreSchema, this fills them, and the /setup page makes the
10
+ first administrator. A database that has the group is never touched.
11
+ */
12
+ export default async () => {
13
+ try {
14
+ const [existing] = await db.select({ name: group.name }).from(group).where(eq(group.name, 'system:Administrators')).limit(1);
15
+ if(existing) return [null, { seeded: false }];
16
+ await seedDatabase({ log: () => {} });
17
+ return [null, { seeded: true }];
18
+ } catch(error) {
19
+ return [{ code: 500, msg: `A new database could not be set up: ${String(error.cause?.message || error.message).slice(0, 300)}` }, null];
20
+ }
21
+ };
@@ -0,0 +1,130 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import db from '../../db/index.js';
3
+ import * as coreSchema from '../../db/schema.js';
4
+
5
+ /*
6
+ Brings kempo's own tables in step with the installed version of kempo, so an update never needs a command typed.
7
+ It runs when the server starts, before the site serves anything.
8
+
9
+ It only ever adds. It looks at which of the schema's tables, columns, indexes and constraints the database already
10
+ has, and asks drizzle-kit's migration generator (the one `drizzle-kit push` is built on) for the difference between
11
+ "what the database has" and "what the schema says", so the new indexes and foreign keys come out exactly as a push
12
+ would make them. What the database has is the schema with every missing piece cut out, so the difference can only
13
+ be additions: it can never contain a DROP or a TRUNCATE, and a table that an extension or the site made is never
14
+ looked at. A column that kempo no longer has stays where it is. A change to something that already exists (a new
15
+ type, a NOT NULL) is not detected here: that is what a release's own migration step is for.
16
+
17
+ Each statement is applied on its own, so one the database refuses (a new unique index over rows that already break
18
+ it, a NOT NULL column with no default on a table that has rows) is reported without holding back the rest. Several
19
+ processes starting together queue on an advisory lock, and the one after the first finds nothing left to do.
20
+
21
+ (drizzle-kit's `pushSchema` is not used: it drops the parameters of its own introspection queries, which fails on any
22
+ table with a composite key and then ends the process, and for a type change it proposes TRUNCATE ... CASCADE.)
23
+ */
24
+ const LOCK_KEY = 'kempo:sync-core-schema';
25
+
26
+ const ADDITIVE = [
27
+ /^CREATE TABLE /i,
28
+ /^CREATE (UNIQUE )?INDEX /i,
29
+ /^CREATE TYPE /i,
30
+ /^ALTER TABLE .+ ADD COLUMN /i,
31
+ /^ALTER TABLE .+ ADD CONSTRAINT /i,
32
+ ];
33
+
34
+ /* A last check on what the generator produced: anything that is not an addition is never run. */
35
+ export const isAdditive = statement => ADDITIVE.some(pattern => pattern.test(statement.trim()));
36
+
37
+ /* One plain sentence for a statement, for the log and the Updates screen. */
38
+ export const describe = statement => {
39
+ const text = statement.trim();
40
+ const named = (pattern, make) => {
41
+ const match = text.match(pattern);
42
+ return match ? make(...match.slice(1)) : null;
43
+ };
44
+ return named(/^CREATE TABLE "([^"]+)"/i, table => `Created table ${table}`)
45
+ ?? named(/^ALTER TABLE "([^"]+)" ADD COLUMN "([^"]+)"/i, (table, column) => `Added column ${table}.${column}`)
46
+ ?? named(/^CREATE (?:UNIQUE )?INDEX "([^"]+)"/i, index => `Created index ${index}`)
47
+ ?? named(/^ALTER TABLE "([^"]+)" ADD CONSTRAINT "([^"]+)"/i, (table, constraint) => `Added ${constraint} to ${table}`)
48
+ ?? text.replace(/\s+/g, ' ').slice(0, 120);
49
+ };
50
+
51
+ const rowsOf = result => (Array.isArray(result) ? result : result.rows);
52
+
53
+ /* The names of what the database already has in the public schema. */
54
+ const readDatabase = async executor => {
55
+ const names = async (query, field) => new Set(rowsOf(await executor.execute(query)).map(row => row[field]));
56
+ const columns = rowsOf(await executor.execute(sql`select table_name, column_name from information_schema.columns where table_schema = 'public'`));
57
+ return {
58
+ tables: await names(sql`select table_name from information_schema.tables where table_schema = 'public'`, 'table_name'),
59
+ columns: new Set(columns.map(row => `${row.table_name}.${row.column_name}`)),
60
+ indexes: await names(sql`select indexname from pg_indexes where schemaname = 'public'`, 'indexname'),
61
+ constraints: await names(sql`select conname from pg_constraint join pg_namespace on pg_namespace.oid = pg_constraint.connamespace where nspname = 'public'`, 'conname'),
62
+ enums: await names(sql`select typname from pg_type join pg_namespace on pg_namespace.oid = pg_type.typnamespace where typtype = 'e' and nspname = 'public'`, 'typname'),
63
+ };
64
+ };
65
+
66
+ /* The schema snapshot with everything the database lacks cut out: what drizzle-kit would see if it had introspected the database. */
67
+ const withoutMissing = (snapshot, existing) => {
68
+ const prev = structuredClone(snapshot);
69
+ for(const key of Object.keys(prev.enums)){
70
+ if(!existing.enums.has(prev.enums[key].name)) delete prev.enums[key];
71
+ }
72
+ for(const [key, table] of Object.entries(prev.tables)){
73
+ if(!existing.tables.has(table.name)){
74
+ delete prev.tables[key];
75
+ continue;
76
+ }
77
+ for(const name of Object.keys(table.columns)){
78
+ if(!existing.columns.has(`${table.name}.${name}`)) delete table.columns[name];
79
+ }
80
+ for(const name of Object.keys(table.indexes)){
81
+ if(!existing.indexes.has(name)) delete table.indexes[name];
82
+ }
83
+ for(const group of [table.foreignKeys, table.uniqueConstraints, table.compositePrimaryKeys ?? {}]){
84
+ for(const id of Object.keys(group)){
85
+ if(!existing.constraints.has(group[id].name)) delete group[id];
86
+ }
87
+ }
88
+ }
89
+ return prev;
90
+ };
91
+
92
+ /* What the last run did, for the Updates screen. */
93
+ let lastRun = { ran: false, applied: [], failed: [] };
94
+
95
+ export const getSchemaSyncResult = () => lastRun;
96
+
97
+ /*
98
+ Resolves to [error, { applied, failed }]: `applied` is a list of sentences, `failed` is { change, reason } for what
99
+ the database refused. `schema` and `executor` are for tests and extensions: other tables, in another database.
100
+ */
101
+ export default async ({ schema = coreSchema, executor = db } = {}) => {
102
+ try {
103
+ const { generateDrizzleJson, generateMigration } = await import('drizzle-kit/api');
104
+ const wanted = generateDrizzleJson(schema);
105
+ const result = { applied: [], failed: [] };
106
+
107
+ await executor.transaction(async tx => {
108
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${LOCK_KEY}::text))`);
109
+ const statements = await generateMigration(withoutMissing(wanted, await readDatabase(tx)), wanted);
110
+
111
+ for(const statement of statements){
112
+ if(!isAdditive(statement)){
113
+ result.failed.push({ change: describe(statement), reason: 'Kempo only adds to its tables, so this was not run' });
114
+ continue;
115
+ }
116
+ try {
117
+ await tx.transaction(inner => inner.execute(sql.raw(statement)));
118
+ result.applied.push(describe(statement));
119
+ } catch(error) {
120
+ result.failed.push({ change: describe(statement), reason: String(error.cause?.message || error.message).slice(0, 300) });
121
+ }
122
+ }
123
+ });
124
+
125
+ if(schema === coreSchema) lastRun = { ran: true, ...result };
126
+ return [null, result];
127
+ } catch(error) {
128
+ return [{ code: 500, msg: `The database could not be checked against this version of kempo: ${String(error.cause?.message || error.message).slice(0, 300)}` }, null];
129
+ }
130
+ };
@@ -55,6 +55,8 @@ export const SYSTEM_PERMISSIONS = [
55
55
  { name: 'system:emails:read', description: 'View the log of emails, including what they said' },
56
56
  { name: 'system:emails:manage', description: 'Send a test email and try failed emails again' },
57
57
 
58
+ { name: 'system:updates:manage', description: 'See, check for and install kempo updates, which restarts the server' },
59
+
58
60
  { name: 'system:admin:access', description: 'Access admin panel' },
59
61
  ];
60
62