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.
- package/db/migrations/095_rest_catalogs.sql +59 -0
- package/db/migrations/096_developer_email.sql +15 -0
- package/docs/apex-feature-parity.md +12 -9
- package/docs/guide/03-builder.md +37 -1
- package/docs/guide/04-pages-and-regions.md +37 -4
- package/docs/guide/12-development.md +18 -1
- package/docs/guide/19-rest-data-sources.md +39 -1
- package/examples/hr/hr_59_gantt_editing.sql +21 -0
- package/examples/hr/hr_60_tree_charts.sql +46 -0
- package/package.json +1 -1
- package/public/app.css +44 -0
- package/public/app.js +126 -0
- package/public/builder.css +1 -1
- package/public/sw.js +5 -2
- package/src/app.ts +6 -0
- package/src/builder/find-workspace.ts +120 -0
- package/src/builder/home.ts +2 -0
- package/src/builder/mail.ts +39 -0
- package/src/builder/region-settings.ts +19 -1
- package/src/builder/rest-catalogs.ts +424 -0
- package/src/builder/routes.ts +32 -6
- package/src/builder/shared.ts +2 -0
- package/src/builder/workspace-transfer.ts +136 -0
- package/src/i18n/ar.ts +14 -0
- package/src/i18n/cs.ts +14 -0
- package/src/i18n/da.ts +14 -0
- package/src/i18n/de.ts +14 -0
- package/src/i18n/el.ts +14 -0
- package/src/i18n/es.ts +14 -0
- package/src/i18n/fi.ts +14 -0
- package/src/i18n/fr.ts +14 -0
- package/src/i18n/he.ts +14 -0
- package/src/i18n/it.ts +14 -0
- package/src/i18n/ja.ts +14 -0
- package/src/i18n/ko.ts +14 -0
- package/src/i18n/nb.ts +14 -0
- package/src/i18n/pl.ts +14 -0
- package/src/i18n/pt.ts +14 -0
- package/src/i18n/ru.ts +14 -0
- package/src/i18n/sv.ts +14 -0
- package/src/i18n/tr.ts +14 -0
- package/src/i18n/uk.ts +14 -0
- package/src/i18n/zh.ts +14 -0
- package/src/i18n.ts +28 -0
- package/src/restcatalog.ts +605 -0
- package/src/runtime/charts.ts +296 -12
- package/src/runtime/data-reporter.ts +48 -16
- package/src/runtime/gantt-move.ts +63 -0
- package/src/runtime/regions.ts +3 -0
- package/src/runtime/routes.ts +39 -0
- package/src/websources.ts +11 -3
- 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
|
+
}
|