pgkiln 0.40.0 → 0.41.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 (52) hide show
  1. package/db/migrations/095_rest_catalogs.sql +59 -0
  2. package/db/migrations/096_developer_email.sql +15 -0
  3. package/docs/apex-feature-parity.md +12 -9
  4. package/docs/guide/03-builder.md +37 -1
  5. package/docs/guide/04-pages-and-regions.md +37 -4
  6. package/docs/guide/12-development.md +18 -1
  7. package/docs/guide/19-rest-data-sources.md +39 -1
  8. package/examples/hr/hr_59_gantt_editing.sql +21 -0
  9. package/examples/hr/hr_60_tree_charts.sql +46 -0
  10. package/package.json +1 -1
  11. package/public/app.css +44 -0
  12. package/public/app.js +126 -0
  13. package/public/builder.css +1 -1
  14. package/public/sw.js +5 -2
  15. package/src/app.ts +6 -0
  16. package/src/builder/find-workspace.ts +120 -0
  17. package/src/builder/home.ts +2 -0
  18. package/src/builder/mail.ts +39 -0
  19. package/src/builder/region-settings.ts +19 -1
  20. package/src/builder/rest-catalogs.ts +424 -0
  21. package/src/builder/routes.ts +32 -6
  22. package/src/builder/shared.ts +2 -0
  23. package/src/builder/workspace-transfer.ts +136 -0
  24. package/src/i18n/ar.ts +14 -0
  25. package/src/i18n/cs.ts +14 -0
  26. package/src/i18n/da.ts +14 -0
  27. package/src/i18n/de.ts +14 -0
  28. package/src/i18n/el.ts +14 -0
  29. package/src/i18n/es.ts +14 -0
  30. package/src/i18n/fi.ts +14 -0
  31. package/src/i18n/fr.ts +14 -0
  32. package/src/i18n/he.ts +14 -0
  33. package/src/i18n/it.ts +14 -0
  34. package/src/i18n/ja.ts +14 -0
  35. package/src/i18n/ko.ts +14 -0
  36. package/src/i18n/nb.ts +14 -0
  37. package/src/i18n/pl.ts +14 -0
  38. package/src/i18n/pt.ts +14 -0
  39. package/src/i18n/ru.ts +14 -0
  40. package/src/i18n/sv.ts +14 -0
  41. package/src/i18n/tr.ts +14 -0
  42. package/src/i18n/uk.ts +14 -0
  43. package/src/i18n/zh.ts +14 -0
  44. package/src/i18n.ts +28 -0
  45. package/src/restcatalog.ts +605 -0
  46. package/src/runtime/charts.ts +296 -12
  47. package/src/runtime/data-reporter.ts +48 -16
  48. package/src/runtime/gantt-move.ts +63 -0
  49. package/src/runtime/regions.ts +3 -0
  50. package/src/runtime/routes.ts +39 -0
  51. package/src/websources.ts +11 -3
  52. package/src/workspace-export.ts +240 -0
package/src/websources.ts CHANGED
@@ -41,6 +41,8 @@ export interface RestParam {
41
41
  in: ParamLocation;
42
42
  default?: string;
43
43
  required?: boolean;
44
+ /** (095) the name the service uses, when it isn't a valid parameter name (e.g. pageSize, X-Request-ID): query, header and body parameters */
45
+ key?: string;
44
46
  }
45
47
  export type ColumnType = 'text' | 'number' | 'integer' | 'boolean' | 'date' | 'timestamp' | 'json';
46
48
  export interface RestColumn {
@@ -90,6 +92,8 @@ export const COLUMN_TYPES: Record<ColumnType, string> = {
90
92
  const PARAM_IN = ['query', 'path', 'header', 'body'];
91
93
  const NAME = /^[a-z_][a-z0-9_]{0,62}$/;
92
94
  const HEADER = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/;
95
+ // (095) a parameter's name at the service: printable, no spaces
96
+ const KEY = /^[\x21-\x7e]{1,100}$/;
93
97
  /** headers a source may not set itself (the credential and the transport own them) */
94
98
  const RESERVED_HEADERS = new Set(['authorization', 'proxy-authorization', 'cookie', 'host', 'content-length', 'transfer-encoding', 'connection']);
95
99
 
@@ -118,7 +122,11 @@ export function sourceProblems(s: { url?: unknown; params?: unknown; columns?: u
118
122
  else seen.add(p.name);
119
123
  if (p && !PARAM_IN.includes(p.in)) out.push(`Parameter ${p?.name}: "in" is one of ${PARAM_IN.join(', ')}.`);
120
124
  if (p?.in === 'header' && !HEADER.test(p.name.replace(/_/g, '-'))) out.push(`Parameter ${p.name}: not a valid header name.`);
125
+ else if (p?.in === 'header' && RESERVED_HEADERS.has(String(p.key ?? p.name.replace(/_/g, '-')).toLowerCase()))
126
+ out.push(`Parameter ${p.name}: the header ${p.key ?? p.name.replace(/_/g, '-')} can't be a parameter (use a web credential for authorization).`);
121
127
  if (p?.default !== undefined && typeof p.default !== 'string') out.push(`Parameter ${p.name}: "default" is a string.`);
128
+ if (p?.key !== undefined && (typeof p.key !== 'string' || !KEY.test(p.key) || (p.in === 'header' && !HEADER.test(p.key))))
129
+ out.push(`Parameter ${p.name}: "key" is the service's name for it (at most 100 characters${p.in === 'header' ? ', a valid header name' : ''}).`);
122
130
  }
123
131
  if (typeof s.url === 'string')
124
132
  for (const m of s.url.matchAll(/\{([a-z_][a-z0-9_]*)\}/gi))
@@ -624,12 +632,12 @@ export function buildRequest(s: RestSource, values: Record<string, string | null
624
632
  for (const p of s.params) {
625
633
  if (p.in === 'query') {
626
634
  const v = val(p);
627
- if (v !== '') u.searchParams.set(p.name, v);
635
+ if (v !== '') u.searchParams.set(p.key ?? p.name, v);
628
636
  } else if (p.in === 'header') {
629
637
  const v = val(p);
630
638
  if (/[\r\n]/.test(v)) throw new WebError(`Parameter ${p.name}: a header value is one line.`);
631
- if (v !== '') headers[p.name.replace(/_/g, '-')] = v;
632
- } else if (p.in === 'body') bodyParams[p.name] = val(p);
639
+ if (v !== '') headers[(p.key ?? p.name.replace(/_/g, '-')).toLowerCase()] = v;
640
+ } else if (p.in === 'body') bodyParams[p.key ?? p.name] = val(p);
633
641
  }
634
642
  url = u.toString();
635
643
  for (const [k, v] of Object.entries(s.headers ?? {})) if (!RESERVED_HEADERS.has(k.toLowerCase())) headers[k.toLowerCase()] = String(v);
@@ -0,0 +1,240 @@
1
+ import type { Client } from './db.ts';
2
+ import { owner } from './db.ts';
3
+ import { catalogDocument, fromCatalogDocument, insertCatalog, loadCatalog, replaceCatalog, wholeCatalogProblems } from './restcatalog.ts';
4
+
5
+ // Workspace utilities → Export / import (APEX: Workspace Utilities → Export,
6
+ // "Workspace" components): the components of a workspace as one JSON file
7
+ // (format "pgkiln-workspace/1"), and that file imported into the current
8
+ // workspace of this or another installation. Applications travel on their own
9
+ // (an application export each); this file holds what the workspace shares:
10
+ //
11
+ // settings description, workspace message, environment banner, preferences
12
+ // developers its developers (user names and workspace administrators; never passwords)
13
+ // app_groups application groups, with the aliases of their applications
14
+ // extension_links the Extensions menu
15
+ // rest_catalogs REST source catalogs and their services
16
+ // sql_scripts the SQL Workshop's saved scripts
17
+ // blueprints saved blueprints
18
+ // data_generators saved data generators (Sample Data)
19
+ //
20
+ // Not exported: the workspace's name (the importer's workspace keeps its own),
21
+ // isolation and its database roles (installation-specific), script runs and
22
+ // other history. An import adds and, by choice, replaces components with the
23
+ // same name; it never deletes. Developers are only added when the importing
24
+ // installation has a developer of that name.
25
+
26
+ export const WORKSPACE_FORMAT = 'pgkiln-workspace/1';
27
+ export const PARTS = ['settings', 'developers', 'app_groups', 'extension_links', 'rest_catalogs', 'sql_scripts', 'blueprints', 'data_generators'] as const;
28
+ export type Part = (typeof PARTS)[number];
29
+ export const PART_LABELS: Record<Part, string> = {
30
+ settings: 'Settings: description, message, banner and preferences',
31
+ developers: 'Developers and workspace administrators',
32
+ app_groups: 'Application groups',
33
+ extension_links: 'Extension links',
34
+ rest_catalogs: 'REST source catalogs',
35
+ sql_scripts: 'SQL scripts',
36
+ blueprints: 'Blueprints',
37
+ data_generators: 'Data generators',
38
+ };
39
+
40
+ const MAX_ITEMS = 5000;
41
+ const BANNER_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'gray'];
42
+ type Json = Record<string, any>;
43
+ const isObj = (v: unknown): v is Json => !!v && typeof v === 'object' && !Array.isArray(v);
44
+
45
+ /** The workspace's components as a document (the parts asked for). */
46
+ export async function exportWorkspace(workspaceId: number, parts: readonly Part[] = PARTS, meta: { version?: string; by?: string | null } = {}) {
47
+ const w = await owner.one('select * from meta.workspace where id = $1', [workspaceId]);
48
+ if (!w) throw new Error('No such workspace.');
49
+ const want = (p: Part) => parts.includes(p);
50
+ const doc: Json = { format: WORKSPACE_FORMAT, exported_at: new Date().toISOString(), pgkiln: meta.version ?? null, exported_by: meta.by ?? null, workspace: w.name };
51
+ if (want('settings'))
52
+ doc.settings = { description: w.description, message: w.message, banner_label: w.banner_label, banner_color: w.banner_color, sql_workshop: w.sql_workshop, rest_services: w.rest_services };
53
+ if (want('developers'))
54
+ doc.developers = (await owner.query('select username, is_admin as admin from meta.workspace_member where workspace_id = $1 order by username', [workspaceId])).rows;
55
+ if (want('app_groups'))
56
+ doc.app_groups = (await owner.query(
57
+ `select g.name, g.description,
58
+ coalesce((select json_agg(a.alias order by a.alias) from meta.app_group_member m join meta.app a on a.id = m.app_id where m.group_id = g.id), '[]') as apps
59
+ from meta.app_group g where g.workspace_id = $1 order by lower(g.name)`, [workspaceId])).rows;
60
+ if (want('extension_links'))
61
+ doc.extension_links = (await owner.query('select label, url, description, position from meta.workspace_link where workspace_id = $1 order by position, lower(label)', [workspaceId])).rows;
62
+ if (want('rest_catalogs')) {
63
+ const ids = (await owner.query<{ id: number }>('select id from meta.rest_catalog where workspace_id = $1 order by lower(name)', [workspaceId])).rows;
64
+ doc.rest_catalogs = [];
65
+ for (const { id } of ids) doc.rest_catalogs.push(catalogDocument((await loadCatalog(id))!));
66
+ }
67
+ if (want('sql_scripts'))
68
+ doc.sql_scripts = (await owner.query('select name, description, content from meta.sql_script where workspace_id = $1 order by name', [workspaceId])).rows;
69
+ if (want('blueprints'))
70
+ doc.blueprints = (await owner.query('select name, spec from meta.blueprint where workspace_id = $1 order by lower(name), id', [workspaceId])).rows;
71
+ if (want('data_generators'))
72
+ // (seed is a bigint: pg returns a string)
73
+ doc.data_generators = (await owner.query('select name, description, schema_name, seed, tables from meta.data_generator where workspace_id = $1 order by name', [workspaceId])).rows
74
+ .map((g) => ({ ...g, seed: g.seed === null ? null : Number(g.seed) }));
75
+ return doc;
76
+ }
77
+
78
+ /** What a workspace file holds: the parts and their sizes (for the import form). */
79
+ export function describeWorkspaceFile(doc: unknown): { workspace: string | null; exported_at: string | null; counts: Partial<Record<Part, number>> } {
80
+ if (!isObj(doc) || doc.format !== WORKSPACE_FORMAT) throw new Error(`Not a workspace export (format ${WORKSPACE_FORMAT}).`);
81
+ const counts: Partial<Record<Part, number>> = {};
82
+ for (const p of PARTS) {
83
+ const v = doc[p];
84
+ if (v === undefined) continue;
85
+ if (p === 'settings' ? !isObj(v) : !Array.isArray(v)) throw new Error(`The file's "${p}" is not ${p === 'settings' ? 'an object' : 'a list'}.`);
86
+ if (Array.isArray(v) && v.length > MAX_ITEMS) throw new Error(`The file has more than ${MAX_ITEMS} ${p.replace('_', ' ')}.`);
87
+ counts[p] = Array.isArray(v) ? v.length : 1;
88
+ }
89
+ return { workspace: typeof doc.workspace === 'string' ? doc.workspace : null, exported_at: typeof doc.exported_at === 'string' ? doc.exported_at : null, counts };
90
+ }
91
+
92
+ export interface ImportResult {
93
+ added: number;
94
+ replaced: number;
95
+ skipped: number;
96
+ lines: string[];
97
+ }
98
+
99
+ /**
100
+ * Import a workspace file into a workspace (inside the caller's transaction:
101
+ * any error rolls everything back). `replace`: a component with the same name
102
+ * is replaced; otherwise it is kept and the file's one skipped.
103
+ */
104
+ export async function importWorkspace(c: Client, workspaceId: number, doc: unknown, parts: readonly Part[], opts: { replace: boolean; by: string | null }): Promise<ImportResult> {
105
+ describeWorkspaceFile(doc);
106
+ const d = doc as Json;
107
+ const r: ImportResult = { added: 0, replaced: 0, skipped: 0, lines: [] };
108
+ const want = (p: Part) => parts.includes(p) && d[p] !== undefined;
109
+ const text = (v: unknown, max: number) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max).trim() : null);
110
+ const tally = (what: string, added: number, replaced: number, skipped: number, extra = '') => {
111
+ r.added += added;
112
+ r.replaced += replaced;
113
+ r.skipped += skipped;
114
+ const bits = [added && `${added} added`, replaced && `${replaced} replaced`, skipped && `${skipped} kept (same name)`].filter(Boolean);
115
+ r.lines.push(`${what}: ${bits.length ? bits.join(', ') : 'nothing to import'}${extra}.`);
116
+ };
117
+ /** insert, or replace / skip on the same name; returns 'added' | 'replaced' | 'skipped' */
118
+ const upsert = async (existing: { id: number } | undefined, insert: () => Promise<unknown>, update: (id: number) => Promise<unknown>) => {
119
+ if (!existing) return (await insert(), 'added' as const);
120
+ if (!opts.replace) return 'skipped' as const;
121
+ return (await update(existing.id), 'replaced' as const);
122
+ };
123
+ const count = () => ({ added: 0, replaced: 0, skipped: 0 });
124
+
125
+ if (want('settings')) {
126
+ const s = d.settings as Json;
127
+ await c.query(
128
+ `update meta.workspace set description = $2, message = $3, banner_label = $4, banner_color = coalesce($5, banner_color),
129
+ sql_workshop = coalesce($6, sql_workshop), rest_services = coalesce($7, rest_services) where id = $1`,
130
+ [workspaceId, text(s.description, 2000), text(s.message, 2000), text(s.banner_label, 40), BANNER_COLORS.includes(s.banner_color) ? s.banner_color : null,
131
+ typeof s.sql_workshop === 'boolean' ? s.sql_workshop : null, typeof s.rest_services === 'boolean' ? s.rest_services : null],
132
+ );
133
+ r.lines.push('Settings: description, message, banner and preferences set from the file.');
134
+ }
135
+
136
+ if (want('developers')) {
137
+ const devs = (d.developers as Json[]).filter((x) => isObj(x) && typeof x.username === 'string');
138
+ const known = new Set((await c.query<{ username: string }>('select username from meta.developer where username = any($1::text[])', [devs.map((x) => x.username)])).rows.map((x) => x.username));
139
+ let added = 0;
140
+ let updated = 0;
141
+ for (const x of devs.filter((x) => known.has(x.username))) {
142
+ const res = await c.query<{ inserted: boolean }>(
143
+ `insert into meta.workspace_member (workspace_id, username, is_admin) values ($1, $2, $3)
144
+ on conflict (workspace_id, username) do update set is_admin = case when $4 then excluded.is_admin else meta.workspace_member.is_admin end
145
+ returning (xmax = 0) as inserted`, [workspaceId, x.username, x.admin === true, opts.replace]);
146
+ if (res.rows[0]?.inserted) added++;
147
+ else updated++;
148
+ }
149
+ const missing = devs.length - known.size;
150
+ tally('Developers', added, opts.replace ? updated : 0, opts.replace ? 0 : updated, missing ? `; ${missing} not a developer of this installation (add them under Developers first)` : '');
151
+ }
152
+
153
+ if (want('app_groups')) {
154
+ const n = count();
155
+ let placed = 0;
156
+ for (const g of d.app_groups as Json[]) {
157
+ if (!isObj(g)) continue;
158
+ const existing = (await c.query<{ id: number }>('select id from meta.app_group where workspace_id = $1 and name = $2', [workspaceId, g.name])).rows[0];
159
+ const what = await upsert(existing,
160
+ () => c.query('insert into meta.app_group (workspace_id, name, description) values ($1, $2, $3)', [workspaceId, g.name, text(g.description, 2000)]),
161
+ (id) => c.query('update meta.app_group set description = $2 where id = $1', [id, text(g.description, 2000)]));
162
+ n[what]++;
163
+ if (what === 'skipped') continue;
164
+ // the group's applications: those of this workspace with these aliases
165
+ const res = await c.query(
166
+ `insert into meta.app_group_member (app_id, group_id)
167
+ select a.id, (select id from meta.app_group where workspace_id = $1 and name = $2) from meta.app a
168
+ where a.alias = any($3::text[]) and meta.app_workspace(a.id) = $1
169
+ on conflict (app_id) do update set group_id = excluded.group_id`,
170
+ [workspaceId, g.name, Array.isArray(g.apps) ? g.apps.filter((x: unknown) => typeof x === 'string') : []]);
171
+ placed += res.rowCount ?? 0;
172
+ }
173
+ tally('Application groups', n.added, n.replaced, n.skipped, placed ? `; ${placed} application(s) of this workspace placed in them` : '');
174
+ }
175
+
176
+ if (want('extension_links')) {
177
+ const n = count();
178
+ for (const l of d.extension_links as Json[]) {
179
+ if (!isObj(l)) continue;
180
+ const existing = (await c.query<{ id: number }>('select id from meta.workspace_link where workspace_id = $1 and label = $2', [workspaceId, l.label])).rows[0];
181
+ const vals = [l.url, text(l.description, 500), Number.isInteger(l.position) ? l.position : 0];
182
+ n[await upsert(existing,
183
+ () => c.query('insert into meta.workspace_link (workspace_id, label, url, description, position) values ($1, $2, $3, $4, $5)', [workspaceId, l.label, ...vals]),
184
+ (id) => c.query('update meta.workspace_link set url = $2, description = $3, position = $4 where id = $1', [id, ...vals]))]++;
185
+ }
186
+ tally('Extension links', n.added, n.replaced, n.skipped);
187
+ }
188
+
189
+ if (want('rest_catalogs')) {
190
+ const n = count();
191
+ for (const raw of d.rest_catalogs as Json[]) {
192
+ const cat = fromCatalogDocument(isObj(raw) ? raw : {});
193
+ const problems = wholeCatalogProblems(cat);
194
+ if (problems.length) throw new Error(`REST source catalog ${cat.name || '?'}: ${problems.slice(0, 5).join(' ')}`);
195
+ const existing = (await c.query<{ id: number }>('select id from meta.rest_catalog where workspace_id = $1 and lower(name) = lower($2)', [workspaceId, cat.name])).rows[0];
196
+ n[await upsert(existing, () => insertCatalog(c, workspaceId, cat, opts.by), (id) => replaceCatalog(c, id, cat))]++;
197
+ }
198
+ tally('REST source catalogs', n.added, n.replaced, n.skipped);
199
+ }
200
+
201
+ if (want('sql_scripts')) {
202
+ const n = count();
203
+ for (const x of d.sql_scripts as Json[]) {
204
+ if (!isObj(x)) continue;
205
+ const existing = (await c.query<{ id: number }>('select id from meta.sql_script where workspace_id = $1 and name = $2', [workspaceId, x.name])).rows[0];
206
+ const content = typeof x.content === 'string' ? x.content : '';
207
+ n[await upsert(existing,
208
+ () => c.query('insert into meta.sql_script (workspace_id, name, description, content, created_by, updated_by) values ($1, $2, $3, $4, $5, $5)', [workspaceId, x.name, text(x.description, 2000), content, opts.by]),
209
+ (id) => c.query('update meta.sql_script set description = $2, content = $3, updated_by = $4, updated_at = now() where id = $1', [id, text(x.description, 2000), content, opts.by]))]++;
210
+ }
211
+ tally('SQL scripts', n.added, n.replaced, n.skipped);
212
+ }
213
+
214
+ if (want('blueprints')) {
215
+ const n = count();
216
+ for (const x of d.blueprints as Json[]) {
217
+ if (!isObj(x) || !isObj(x.spec)) throw new Error(`Blueprint ${isObj(x) ? x.name : '?'}: the specification is an object.`);
218
+ const existing = (await c.query<{ id: number }>('select id from meta.blueprint where workspace_id = $1 and lower(trim(name)) = lower(trim($2)) order by id limit 1', [workspaceId, x.name])).rows[0];
219
+ n[await upsert(existing,
220
+ () => c.query('insert into meta.blueprint (workspace_id, name, spec, created_by) values ($1, $2, $3, $4)', [workspaceId, x.name, x.spec, opts.by]),
221
+ (id) => c.query('update meta.blueprint set spec = $2, updated_at = now() where id = $1', [id, x.spec]))]++;
222
+ }
223
+ tally('Blueprints', n.added, n.replaced, n.skipped);
224
+ }
225
+
226
+ if (want('data_generators')) {
227
+ const n = count();
228
+ for (const x of d.data_generators as Json[]) {
229
+ if (!isObj(x)) continue;
230
+ const existing = (await c.query<{ id: number }>('select id from meta.data_generator where workspace_id = $1 and name = $2', [workspaceId, x.name])).rows[0];
231
+ const seed = Number.isInteger(x.seed) ? x.seed : typeof x.seed === 'string' && /^\d{1,10}$/.test(x.seed) ? Number(x.seed) : null;
232
+ const vals = [text(x.description, 1000), x.schema_name, seed, JSON.stringify(Array.isArray(x.tables) ? x.tables : [])];
233
+ n[await upsert(existing,
234
+ () => c.query('insert into meta.data_generator (workspace_id, name, description, schema_name, seed, tables, created_by, updated_by) values ($1, $2, $3, $4, $5, $6, $7, $7)', [workspaceId, x.name, ...vals, opts.by]),
235
+ (id) => c.query('update meta.data_generator set description = $2, schema_name = $3, seed = $4, tables = $5, updated_by = $6, updated_at = now() where id = $1', [id, ...vals, opts.by]))]++;
236
+ }
237
+ tally('Data generators', n.added, n.replaced, n.skipped);
238
+ }
239
+ return r;
240
+ }