pgkiln 0.82.0 → 0.84.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/153_component_events.sql +21 -0
- package/db/migrations/154_notifications.sql +186 -0
- package/docs/apex-feature-parity.md +4 -3
- package/docs/guide/03-builder.md +10 -1
- package/docs/guide/04-pages-and-regions.md +44 -1
- package/docs/guide/06-processing.md +22 -1
- package/docs/guide/07-dynamic-actions.md +28 -0
- package/docs/guide/09-reference.md +15 -0
- package/docs/guide/11-from-apex.md +3 -1
- package/docs/guide/12-development.md +16 -1
- package/package.json +1 -1
- package/public/app.css +31 -0
- package/public/app.js +163 -4
- package/public/builder.css +25 -2
- package/public/data-modeler.js +8 -5
- package/public/sql-monitor.js +5 -2
- package/public/sw.js +5 -1
- package/src/app.ts +2 -0
- package/src/builder/ai-builder.ts +50 -46
- package/src/builder/api.ts +49 -49
- package/src/builder/autorest.ts +92 -87
- package/src/builder/changelog.ts +39 -37
- package/src/builder/code-editor.ts +1 -1
- package/src/builder/components.ts +13 -8
- package/src/builder/data-modeler.ts +89 -84
- package/src/builder/data-security.ts +39 -37
- package/src/builder/dataload.ts +69 -65
- package/src/builder/datapump.ts +108 -97
- package/src/builder/ddl-tools.ts +38 -34
- package/src/builder/document-view.ts +11 -11
- package/src/builder/i18n/de.ts +1635 -0
- package/src/builder/i18n/en.ts +1635 -0
- package/src/builder/i18n/es.ts +1635 -0
- package/src/builder/i18n/fr.ts +1635 -0
- package/src/builder/i18n/it.ts +1635 -0
- package/src/builder/i18n/ja.ts +1635 -0
- package/src/builder/i18n/ko.ts +1635 -0
- package/src/builder/i18n/nl.ts +1635 -0
- package/src/builder/i18n/pt.ts +1635 -0
- package/src/builder/i18n/zh-cn.ts +1635 -0
- package/src/builder/i18n/zh-tw.ts +1635 -0
- package/src/builder/i18n.ts +18 -0
- package/src/builder/notification-settings.ts +27 -0
- package/src/builder/object-actions.ts +18 -16
- package/src/builder/object-browser.ts +2 -2
- package/src/builder/querybuilder.ts +42 -36
- package/src/builder/quicksql.ts +16 -18
- package/src/builder/region-settings.ts +201 -199
- package/src/builder/report-settings.ts +64 -60
- package/src/builder/rest-modules.ts +145 -143
- package/src/builder/routes.ts +3 -0
- package/src/builder/sampledata.ts +59 -52
- package/src/builder/scheduling.ts +180 -159
- package/src/builder/scripts.ts +71 -65
- package/src/builder/sql-charts.ts +36 -30
- package/src/builder/sql-monitor.ts +95 -89
- package/src/builder/sql-utilities.ts +47 -40
- package/src/builder/top-sql.ts +21 -17
- package/src/builder/unload.ts +48 -44
- package/src/builder/worksheet.ts +3 -3
- package/src/cli/replace.ts +2 -0
- package/src/cron-schedule.ts +47 -17
- package/src/i18n/ar.ts +9 -0
- package/src/i18n/cs.ts +9 -0
- package/src/i18n/da.ts +9 -0
- package/src/i18n/de.ts +9 -0
- package/src/i18n/el.ts +9 -0
- package/src/i18n/es.ts +9 -0
- package/src/i18n/fi.ts +9 -0
- package/src/i18n/fr.ts +9 -0
- package/src/i18n/he.ts +9 -0
- package/src/i18n/it.ts +9 -0
- package/src/i18n/ja.ts +9 -0
- package/src/i18n/ko.ts +9 -0
- package/src/i18n/nb.ts +9 -0
- package/src/i18n/pl.ts +9 -0
- package/src/i18n/pt.ts +9 -0
- package/src/i18n/ru.ts +9 -0
- package/src/i18n/sv.ts +9 -0
- package/src/i18n/tr.ts +9 -0
- package/src/i18n/uk.ts +9 -0
- package/src/i18n/zh.ts +9 -0
- package/src/i18n.ts +18 -0
- package/src/metadata.ts +11 -3
- package/src/runtime/calendar.ts +3 -2
- package/src/runtime/context.ts +2 -0
- package/src/runtime/engine.ts +2 -1
- package/src/runtime/feedback.ts +1 -1
- package/src/runtime/grid.ts +1 -0
- package/src/runtime/lists.ts +43 -11
- package/src/runtime/maps.ts +7 -4
- package/src/runtime/notifications.ts +237 -0
- package/src/runtime/processes.ts +45 -1
- package/src/runtime/regions.ts +11 -15
- package/src/runtime/render.ts +18 -4
- package/src/runtime/routes.ts +16 -4
- package/src/runtime/signin.ts +2 -1
- package/src/runtime/tree.ts +4 -3
- package/src/session.ts +13 -4
package/src/builder/autorest.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
2
|
import { apiRoleProblem } from '../api.ts';
|
|
3
3
|
import { owner } from '../db.ts';
|
|
4
|
-
import { html, raw, type Raw } from '../html.ts';
|
|
4
|
+
import { esc, html, raw, type Raw } from '../html.ts';
|
|
5
5
|
import { icon } from '../icons.ts';
|
|
6
6
|
import { AUTOREST_METHODS, describeColumns, keyColumns, MAX_PAGE, qualified, RELKINDS, type RestObject } from '../runtime/autorest.ts';
|
|
7
7
|
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
@@ -11,6 +11,7 @@ import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs,
|
|
|
11
11
|
import { splitRoles } from './users.ts';
|
|
12
12
|
import { restNav } from './rest-modules.ts';
|
|
13
13
|
import { currentWorkspace, usableSchemas, workshopDb } from './workspaces.ts';
|
|
14
|
+
import { scopedTx as tx, tr, withTranslator, type BuilderTranslate } from './i18n.ts';
|
|
14
15
|
|
|
15
16
|
// (sprint 100, Database Actions DA-11 part 1) SQL Workshop › RESTful Services: AutoREST. A developer publishes a
|
|
16
17
|
// table or view of a schema the SQL Workshop may use through an application of the workspace (meta.rest_object,
|
|
@@ -36,7 +37,9 @@ interface WsApp {
|
|
|
36
37
|
type Row = RestObject & { app_alias: string; app_name: string; db_role: string | null };
|
|
37
38
|
|
|
38
39
|
const many = (v: unknown) => ([] as string[]).concat((v as string | string[] | undefined) ?? []);
|
|
39
|
-
const
|
|
40
|
+
const KINDS = { r: 'table', p: 'table', v: 'view', m: 'mview', f: 'foreign' } as const;
|
|
41
|
+
const kindLabel = (k: string) => (Object.hasOwn(KINDS, k) ? tx(`ar.kind.${KINDS[k as keyof typeof KINDS]}`) : RELKINDS[k] ?? k);
|
|
42
|
+
const anyOf = (roles: string[]) => roles.join(` ${tx('rm.or')} `);
|
|
40
43
|
const autoUrl = (alias: string, o?: string) => `${publicUrl()}/a/${alias}/autorest/${o ? `${o}/` : ''}`;
|
|
41
44
|
|
|
42
45
|
/** The applications of the developer's current workspace. */
|
|
@@ -72,14 +75,14 @@ async function findRel(s: Session, schema: unknown, name: unknown): Promise<Rel
|
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
/** Why `role` may not serve the relation; null when it may. */
|
|
75
|
-
async function roleProblem(role: string | null, rel: Pick<Rel, 'schema' | 'name'>): Promise<string | null> {
|
|
76
|
-
if (!role) return '
|
|
78
|
+
async function roleProblem(t: BuilderTranslate, role: string | null, rel: Pick<Rel, 'schema' | 'name'>): Promise<string | null> {
|
|
79
|
+
if (!role) return t('ar.e.no_role');
|
|
77
80
|
const problem = await apiRoleProblem(role);
|
|
78
|
-
if (problem) return
|
|
81
|
+
if (problem) return t('ar.e.role_problem', { problem });
|
|
79
82
|
const r = await owner.one<{ owns: boolean }>(
|
|
80
83
|
`select pg_has_role($1, c.relowner, 'MEMBER') as owns from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = $2 and c.relname = $3`,
|
|
81
84
|
[role, rel.schema, rel.name]);
|
|
82
|
-
if (r?.owns) return
|
|
85
|
+
if (r?.owns) return t('ar.e.owner', { role, object: `${rel.schema}.${rel.name}` });
|
|
83
86
|
return null;
|
|
84
87
|
}
|
|
85
88
|
|
|
@@ -92,38 +95,39 @@ interface Input {
|
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
/** The form's settings, or the problems with them. */
|
|
95
|
-
async function readInput(b: Record<string, unknown>, rel: Pick<Rel, 'schema' | 'name' | 'kind'>, role: string | null): Promise<{ input: Input; problems: string[] }> {
|
|
98
|
+
async function readInput(t: BuilderTranslate, b: Record<string, unknown>, rel: Pick<Rel, 'schema' | 'name' | 'kind'>, role: string | null): Promise<{ input: Input; problems: string[] }> {
|
|
96
99
|
const problems: string[] = [];
|
|
97
100
|
const methods = AUTOREST_METHODS.filter((m) => many(b.methods).includes(m));
|
|
98
|
-
if (!methods.length) problems.push('
|
|
101
|
+
if (!methods.length) problems.push(t('ar.e.method'));
|
|
99
102
|
const auth = b.auth === 'public' ? 'public' : b.auth === 'token' ? 'token' : null;
|
|
100
|
-
if (!auth) problems.push('
|
|
103
|
+
if (!auth) problems.push(t('rm.e.auth'));
|
|
101
104
|
const size = Number(b.page_size ?? 25);
|
|
102
|
-
if (!Number.isInteger(size) || size < 1 || size > MAX_PAGE) problems.push(
|
|
105
|
+
if (!Number.isInteger(size) || size < 1 || size > MAX_PAGE) problems.push(t('ar.e.page_size', { max: MAX_PAGE }));
|
|
103
106
|
const cols = role ? await describeColumns(owner, rel.schema, rel.name, role) : [];
|
|
104
|
-
if (rel.kind === 'm' && methods.some((m) => m !== 'GET')) problems.push('
|
|
105
|
-
if (!keyColumns(cols).length && methods.some((m) => m === 'PUT' || m === 'DELETE')) problems.push('
|
|
107
|
+
if (rel.kind === 'm' && methods.some((m) => m !== 'GET')) problems.push(t('ar.e.mview'));
|
|
108
|
+
if (!keyColumns(cols).length && methods.some((m) => m === 'PUT' || m === 'DELETE')) problems.push(t('ar.e.no_key'));
|
|
106
109
|
const roles = splitRoles(String(b.roles ?? ''));
|
|
107
|
-
if (roles.some((r) => r.length > 100)) problems.push('
|
|
110
|
+
if (roles.some((r) => r.length > 100)) problems.push(t('rm.e.role_length'));
|
|
108
111
|
return { input: { methods, auth: auth ?? 'token', roles, page_size: size, enabled: b.enabled === undefined ? true : b.enabled === '1' }, problems };
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
const methodChecks = (selected: string[]) =>
|
|
112
|
-
html`<fieldset class="prop-group"><legend
|
|
115
|
+
html`<fieldset class="prop-group"><legend>${tx('ar.methods')}</legend><div class="form-grid">${AUTOREST_METHODS.map((m) =>
|
|
113
116
|
html`<label class="check"><input type="checkbox" name="methods" value="${m}"${selected.includes(m) ? raw(' checked') : ''}> ${m} <small class="help">${
|
|
114
|
-
{
|
|
117
|
+
tx(`ar.m.${m.toLowerCase() as 'get' | 'post' | 'put' | 'delete'}`)}</small></label>`)}</div></fieldset>`;
|
|
115
118
|
|
|
116
119
|
const authChoice = (auth: string) =>
|
|
117
|
-
html`<fieldset class="prop-group"><legend
|
|
118
|
-
<label class="check"><input type="radio" name="auth" value="token"${auth !== 'public' ? raw(' checked') : ''}>
|
|
119
|
-
<label class="check"><input type="radio" name="auth" value="public"${auth === 'public' ? raw(' checked') : ''}>
|
|
120
|
+
html`<fieldset class="prop-group"><legend>${tx('rm.who')}</legend>
|
|
121
|
+
<label class="check"><input type="radio" name="auth" value="token"${auth !== 'public' ? raw(' checked') : ''}> ${tx('rm.auth_token')} <small class="help">${tx('ar.auth_token_help')}</small></label>
|
|
122
|
+
<label class="check"><input type="radio" name="auth" value="public"${auth === 'public' ? raw(' checked') : ''}> ${tx('ar.auth_public')} <small class="help">${tx.code('ar.auth_public_help')}</small></label>
|
|
120
123
|
</fieldset>`;
|
|
121
124
|
|
|
122
125
|
const errorList = (problems: string[]) =>
|
|
123
126
|
problems.length ? html`<div class="alert alert-error" role="alert"><ul class="u-m0">${problems.map((p) => html`<li>${p}</li>`)}</ul></div>` : '';
|
|
124
127
|
|
|
125
128
|
/** The list and the Enable form. */
|
|
126
|
-
|
|
129
|
+
const listPage = (s: Session, q: Record<string, unknown>, problems: string[] = []) => withTranslator(tr(s), () => listPageInner(s, q, problems));
|
|
130
|
+
async function listPageInner(s: Session, q: Record<string, unknown>, problems: string[]) {
|
|
127
131
|
const [list, apps, rels] = await Promise.all([rows(s), workspaceApps(s), relations(s)]);
|
|
128
132
|
const usable = apps.filter((a) => a.db_role);
|
|
129
133
|
const wantedRel = typeof q.schema === 'string' && typeof q.name === 'string' ? `${q.schema}.${q.name}` : '';
|
|
@@ -131,51 +135,52 @@ async function listPage(s: Session, q: Record<string, unknown>, problems: string
|
|
|
131
135
|
const bySchema = new Map<string, Rel[]>();
|
|
132
136
|
for (const r of rels) bySchema.set(r.schema, [...(bySchema.get(r.schema) ?? []), r]);
|
|
133
137
|
const table = list.length
|
|
134
|
-
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
138
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('ar.object')}</th><th>URL</th><th>${tx('rm.application')}</th><th>${tx('ar.methods')}</th><th>${tx('rm.access')}</th></tr></thead><tbody>
|
|
135
139
|
${list.map((o) => html`<tr${o.enabled ? '' : raw(' class="muted"')}>
|
|
136
|
-
<td data-label="
|
|
140
|
+
<td data-label="${tx('ar.object')}"><a href="${PAGE}/${o.id}">${o.schema_name}.${o.object_name}</a>${o.enabled ? '' : html` <span class="tag">${tx('rm.off')}</span>`}</td>
|
|
137
141
|
<td data-label="URL" class="wrap-anywhere"><code>/a/${o.app_alias}/autorest/${o.alias}/</code></td>
|
|
138
|
-
<td data-label="
|
|
139
|
-
<td data-label="
|
|
140
|
-
<td data-label="
|
|
142
|
+
<td data-label="${tx('rm.application')}">${o.app_name}</td>
|
|
143
|
+
<td data-label="${tx('ar.methods')}">${o.methods.join(', ')}</td>
|
|
144
|
+
<td data-label="${tx('rm.access')}">${o.auth === 'public' ? html`<strong>${tx('rm.public')}</strong>` : tx('rm.token')}${o.roles.length ? `, ${tx('rm.role', { roles: anyOf(o.roles) })}` : ''}</td></tr>`)}
|
|
141
145
|
</tbody></table></div>`
|
|
142
|
-
: html`<p class="muted"
|
|
146
|
+
: html`<p class="muted">${tx.rich('ar.none_yet', { menu: raw(`<em>${esc(tx('ar.menu_enable'))}</em>`) })}</p>`;
|
|
143
147
|
const form = html`<form method="post" action="${PAGE}" id="enable">${csrf(s)}
|
|
144
148
|
${errorList(problems)}
|
|
145
149
|
<div class="form-grid">
|
|
146
|
-
<div class="field"><label class="label" for="f_object"
|
|
147
|
-
<select id="f_object" name="object" required><option value=""
|
|
150
|
+
<div class="field"><label class="label" for="f_object">${tx('ar.table_or_view')}</label>
|
|
151
|
+
<select id="f_object" name="object" required><option value="">${tx('ar.choose')}</option>${[...bySchema].map(([schema, rs]) =>
|
|
148
152
|
html`<optgroup label="${schema}">${rs.map((r) => {
|
|
149
153
|
const v = JSON.stringify([r.schema, r.name]);
|
|
150
154
|
return html`<option value="${v}"${v === wantedRelKey ? raw(' selected') : ''}>${r.schema}.${r.name}${r.kind === 'r' || r.kind === 'p' ? '' : ` (${kindLabel(r.kind)})`}</option>`;
|
|
151
155
|
})}</optgroup>`)}</select></div>
|
|
152
|
-
<div class="field"><label class="label" for="f_app"
|
|
153
|
-
<select id="f_app" name="app_id" required>${usable.map((a) => html`<option value="${a.id}"${String(q.app_id ?? '') === String(a.id) ? raw(' selected') : ''}>${a.name} (${a.alias},
|
|
154
|
-
<small class="help"
|
|
155
|
-
<div class="field"><label class="label" for="f_alias"
|
|
156
|
-
<input id="f_alias" name="alias" value="${String(q.alias ?? '')}" placeholder="
|
|
157
|
-
<small class="help">/a/<app>/autorest/<b>name</b>/ —
|
|
158
|
-
<div class="field"><label class="label" for="f_roles"
|
|
159
|
-
<input id="f_roles" name="roles" value="${String(q.roles ?? '')}" placeholder="
|
|
160
|
-
<small class="help"
|
|
161
|
-
<div class="field"><label class="label" for="f_page_size"
|
|
156
|
+
<div class="field"><label class="label" for="f_app">${tx('rm.application')}</label>
|
|
157
|
+
<select id="f_app" name="app_id" required>${usable.map((a) => html`<option value="${a.id}"${String(q.app_id ?? '') === String(a.id) ? raw(' selected') : ''}>${a.name} (${a.alias}, ${tx('ar.runs_as', { role: a.db_role })})</option>`)}</select>
|
|
158
|
+
<small class="help">${tx('ar.app_help')}</small></div>
|
|
159
|
+
<div class="field"><label class="label" for="f_alias">${tx('ar.url_name')}</label>
|
|
160
|
+
<input id="f_alias" name="alias" value="${String(q.alias ?? '')}" placeholder="${tx('ar.object_name_ph')}">
|
|
161
|
+
<small class="help">/a/<app>/autorest/<b>name</b>/ — ${tx('ar.url_name_rule')}</small></div>
|
|
162
|
+
<div class="field"><label class="label" for="f_roles">${tx('rm.roles')}</label>
|
|
163
|
+
<input id="f_roles" name="roles" value="${String(q.roles ?? '')}" placeholder="${tx('ar.any_caller')}">
|
|
164
|
+
<small class="help">${tx('ar.roles_help')}</small></div>
|
|
165
|
+
<div class="field"><label class="label" for="f_page_size">${tx('rm.page_size')}</label>
|
|
162
166
|
<input id="f_page_size" name="page_size" type="number" min="1" max="${MAX_PAGE}" value="${String(q.page_size ?? 25)}">
|
|
163
|
-
<small class="help"
|
|
167
|
+
<small class="help">${tx('ar.page_size_help', { max: MAX_PAGE })}</small></div>
|
|
164
168
|
</div>
|
|
165
169
|
${methodChecks(many(q.methods).length ? many(q.methods) : ['GET'])}
|
|
166
170
|
${authChoice(String(q.auth ?? 'token'))}
|
|
167
|
-
<div class="buttons"><button class="btn btn-hot"${usable.length ? '' : raw(' disabled')}>${icon('bolt')}
|
|
168
|
-
${usable.length ? '' : html`<p class="muted"
|
|
171
|
+
<div class="buttons"><button class="btn btn-hot"${usable.length ? '' : raw(' disabled')}>${icon('bolt')} ${tx('ar.enable')}</button></div>
|
|
172
|
+
${usable.length ? '' : html`<p class="muted">${tx('ar.no_usable_app')}</p>`}
|
|
169
173
|
</form>`;
|
|
170
|
-
const main = html`<h1 class="u-mb1"
|
|
171
|
-
<p class="muted"
|
|
172
|
-
${region('
|
|
173
|
-
${region('
|
|
174
|
-
return shell(s, '
|
|
174
|
+
const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('rest', s)}${restNav('autorest')}
|
|
175
|
+
<p class="muted">${tx.code('ar.intro')} ${tx.rich('ar.intro_module', { module: raw(`<a href="${BASE}/sql/rest/modules">${esc(tx('ar.module_link'))}</a>`) })}</p>
|
|
176
|
+
${region(tx('ar.published'), table)}
|
|
177
|
+
${region(tx('ar.enable_a'), form)}`;
|
|
178
|
+
return shell(s, tx('sql.tool.rest'), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.rest')]], main, 'sql');
|
|
175
179
|
}
|
|
176
180
|
|
|
177
181
|
/** One published object: checks, endpoints, examples, settings. */
|
|
178
|
-
|
|
182
|
+
const detailPage = (s: Session, o: Row, problems: string[] = []) => withTranslator(tr(s), () => detailPageInner(s, o, problems));
|
|
183
|
+
async function detailPageInner(s: Session, o: Row, problems: string[]) {
|
|
179
184
|
const rel = { schema: o.schema_name, name: o.object_name };
|
|
180
185
|
const info = await owner.one<{ kind: string; rls: boolean; force: boolean; policies: number; invoker: boolean; oid: number }>(
|
|
181
186
|
`select c.relkind::text as kind, c.relrowsecurity as rls, c.relforcerowsecurity as force, c.oid::int as oid,
|
|
@@ -183,7 +188,7 @@ async function detailPage(s: Session, o: Row, problems: string[] = []) {
|
|
|
183
188
|
coalesce(c.reloptions && array['security_invoker=true', 'security_invoker=on', 'security_invoker=1'], false) as invoker
|
|
184
189
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = $1 and c.relname = $2`, [o.schema_name, o.object_name]);
|
|
185
190
|
const role = o.db_role;
|
|
186
|
-
const problem = info ? await roleProblem(role, rel) : '
|
|
191
|
+
const problem = info ? await roleProblem(tx, role, rel) : tx('ar.gone');
|
|
187
192
|
const cols = info && role ? await describeColumns(owner, o.schema_name, o.object_name, role) : [];
|
|
188
193
|
const key = keyColumns(cols);
|
|
189
194
|
const privs = info && role && !problem
|
|
@@ -195,24 +200,24 @@ async function detailPage(s: Session, o: Row, problems: string[] = []) {
|
|
|
195
200
|
const check = (ok: boolean | null, text: Raw | string) => html`<li>${ok === null ? 'ℹ' : ok ? '✓' : '✗'} ${text}</li>`;
|
|
196
201
|
const priv = { GET: 'select', POST: 'insert', PUT: 'update', DELETE: 'delete' } as Record<string, string>;
|
|
197
202
|
const checks = html`<ul class="checklist">
|
|
198
|
-
${check(!problem, problem ??
|
|
203
|
+
${check(!problem, problem ?? tx.code('ar.chk.runs_as', { role: role ?? '' }))}
|
|
199
204
|
${privs ? o.methods.map((m) => check(m === 'GET' ? privs.GET || privs.sel : privs[m], privs[m] || (m === 'GET' && privs.sel)
|
|
200
|
-
? html`${m}:
|
|
201
|
-
: html`${m}:
|
|
202
|
-
${info && (info.kind === 'r' || info.kind === 'p') ? check(info.rls ? true : null, info.rls ?
|
|
203
|
-
${info && info.kind === 'v' ? check(info.invoker, info.invoker ? '
|
|
204
|
-
${info ? check(key.length ? true : null, key.length ? html
|
|
205
|
-
${cols.some((c) => !c.readable) ? check(null,
|
|
205
|
+
? html`${m}: ${tx.code('ar.chk.may', { role: role ?? '', priv: priv[m] })}`
|
|
206
|
+
: html`${m}: ${tx.code('ar.chk.may_not', { role: role ?? '', priv: priv[m] })} — <code>grant ${priv[m]} on ${qualified(o)} to ${role};</code>`)) : ''}
|
|
207
|
+
${info && (info.kind === 'r' || info.kind === 'p') ? check(info.rls ? true : null, info.rls ? (info.policies === 1 ? tx('ar.chk.rls_on_1') : tx('ar.chk.rls_on', { n: info.policies })) : tx('ar.chk.rls_off')) : ''}
|
|
208
|
+
${info && info.kind === 'v' ? check(info.invoker, info.invoker ? tx('ar.chk.invoker') : html`${tx('ar.chk.definer')} <code>alter view ${qualified(o)} set (security_invoker = true);</code>`) : ''}
|
|
209
|
+
${info ? check(key.length ? true : null, key.length ? html`${tx('ar.chk.key', { columns: key.map((k) => k.name).join(', ') })} <code>…/${o.alias}/<${key.map((k) => k.name).join(',')}></code>` : tx('ar.chk.no_key')) : ''}
|
|
210
|
+
${cols.some((c) => !c.readable) ? check(null, tx('ar.chk.hidden_columns', { columns: cols.filter((c) => !c.readable).map((c) => c.name).join(', ') })) : ''}
|
|
206
211
|
</ul>`;
|
|
207
212
|
const base = autoUrl(o.app_alias, o.alias);
|
|
208
213
|
const keyEx = key.map((k) => `<${k.name}>`).join(',');
|
|
209
214
|
const endpoints: [string, string, string][] = [];
|
|
210
|
-
if (o.methods.includes('GET')) endpoints.push(['GET', base,
|
|
211
|
-
if (o.methods.includes('POST')) endpoints.push(['POST', base, '
|
|
215
|
+
if (o.methods.includes('GET')) endpoints.push(['GET', base, tx('ar.ep.rows', { max: MAX_PAGE, size: o.page_size })]);
|
|
216
|
+
if (o.methods.includes('POST')) endpoints.push(['POST', base, tx('ar.ep.post')]);
|
|
212
217
|
if (key.length) {
|
|
213
|
-
if (o.methods.includes('GET')) endpoints.push(['GET', `${base}${keyEx}`, '
|
|
214
|
-
if (o.methods.includes('PUT')) endpoints.push(['PUT', `${base}${keyEx}`, '
|
|
215
|
-
if (o.methods.includes('DELETE')) endpoints.push(['DELETE', `${base}${keyEx}`, '
|
|
218
|
+
if (o.methods.includes('GET')) endpoints.push(['GET', `${base}${keyEx}`, tx('ar.ep.one')]);
|
|
219
|
+
if (o.methods.includes('PUT')) endpoints.push(['PUT', `${base}${keyEx}`, tx('ar.ep.put')]);
|
|
220
|
+
if (o.methods.includes('DELETE')) endpoints.push(['DELETE', `${base}${keyEx}`, tx('ar.ep.delete')]);
|
|
216
221
|
}
|
|
217
222
|
const auth = o.auth === 'public' ? '' : ' -H "Authorization: Bearer $TOKEN"';
|
|
218
223
|
const sample = cols.filter((c) => c.readable && c.pk === null).slice(0, 2);
|
|
@@ -227,36 +232,36 @@ async function detailPage(s: Session, o: Row, problems: string[] = []) {
|
|
|
227
232
|
].filter(Boolean).join('\n');
|
|
228
233
|
const fetchEx = `const res = await fetch('${base}?limit=10'${o.auth === 'public' ? '' : `, {\n headers: { Authorization: \`Bearer \${token}\` },\n}`});\nconst { items, has_more } = await res.json();`;
|
|
229
234
|
const openapi = `${publicUrl()}/a/${o.app_alias}/autorest/openapi.json`;
|
|
230
|
-
const main = html`<h1 class="u-mb1"
|
|
231
|
-
<h2 class="u-mb1 wrap-anywhere">${o.schema_name}.${o.object_name} <span class="muted small">${info ? kindLabel(info.kind) : ''} · ${o.enabled ? '
|
|
235
|
+
const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('rest', s)}
|
|
236
|
+
<h2 class="u-mb1 wrap-anywhere">${o.schema_name}.${o.object_name} <span class="muted small">${info ? kindLabel(info.kind) : ''} · ${o.enabled ? tx('ar.enabled_l') : tx('rm.disabled_suffix')}</span></h2>
|
|
232
237
|
${errorList(problems)}
|
|
233
|
-
${!o.enabled ? html`<div class="alert alert-info" role="status"
|
|
238
|
+
${!o.enabled ? html`<div class="alert alert-info" role="status">${tx('ar.disabled_alert')}</div>` : ''}
|
|
234
239
|
<div class="columns">
|
|
235
|
-
${region('
|
|
236
|
-
${region('
|
|
237
|
-
${endpoints.map(([m, u, d]) => html`<tr><td data-label="
|
|
238
|
-
<p class="wrap-anywhere">OpenAPI: <a href="${openapi}" target="_blank" rel="noopener"><code>${openapi}</code></a> <span class="muted small"
|
|
240
|
+
${region(tx('rm.checks'), checks)}
|
|
241
|
+
${region(tx('ar.endpoints'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('rm.method')}</th><th>URL</th><th>${tx('ar.does')}</th></tr></thead><tbody>
|
|
242
|
+
${endpoints.map(([m, u, d]) => html`<tr><td data-label="${tx('rm.method')}"><code>${m}</code></td><td data-label="URL" class="wrap-anywhere"><code>${u}</code></td><td data-label="${tx('ar.does')}">${d}</td></tr>`)}</tbody></table></div>
|
|
243
|
+
<p class="wrap-anywhere">OpenAPI: <a href="${openapi}" target="_blank" rel="noopener"><code>${openapi}</code></a> <span class="muted small">${tx('ar.openapi_note')}</span></p>`)}
|
|
239
244
|
</div>
|
|
240
|
-
${region('
|
|
241
|
-
? html`<p class="muted u-mt0"
|
|
242
|
-
: html`<p class="muted u-mt0"
|
|
245
|
+
${region(tx('rm.try_it'), html`${o.auth === 'public'
|
|
246
|
+
? html`<p class="muted u-mt0">${tx('ar.public_no_token')}</p>`
|
|
247
|
+
: html`<p class="muted u-mt0">${tx.rich('ar.get_token', { token: raw('<code>$TOKEN</code>'), link: raw(`<a href="${BASE}/apps/${o.app_id}/api">${esc(o.app_name)} › REST API</a>`) })}${o.roles.length ? ` ${tx('ar.caller_needs', { roles: anyOf(o.roles) })}` : ''} ${tx('ar.status_codes')}</p>`}
|
|
243
248
|
<h3 class="small">cURL</h3><pre class="source wrap-pre">${curl}</pre>
|
|
244
249
|
<h3 class="small">JavaScript (fetch)</h3><pre class="source wrap-pre">${fetchEx}</pre>`)}
|
|
245
|
-
${region('
|
|
246
|
-
<p class="muted u-mt0 wrap-anywhere"
|
|
250
|
+
${region(tx('ar.settings'), html`<form method="post" action="${PAGE}/${o.id}">${csrf(s)}
|
|
251
|
+
<p class="muted u-mt0 wrap-anywhere">${tx.rich('ar.published_by', { app: raw(`<a href="${BASE}/apps/${o.app_id}">${esc(o.app_name)}</a>`), role: raw(`<code>${esc(role ?? '—')}</code>`), path: raw(`<code>/a/${esc(o.app_alias)}/autorest/${esc(o.alias)}/</code>`) })}</p>
|
|
247
252
|
<div class="form-grid">
|
|
248
|
-
<div class="field"><label class="label" for="f_roles"
|
|
249
|
-
<div class="field"><label class="label" for="f_page_size"
|
|
250
|
-
<div class="field"><label class="label" for="f_enabled"
|
|
253
|
+
<div class="field"><label class="label" for="f_roles">${tx('rm.roles')}</label><input id="f_roles" name="roles" value="${o.roles.join(', ')}" placeholder="${tx('ar.any_caller')}"><small class="help">${tx('ar.roles_help')}</small></div>
|
|
254
|
+
<div class="field"><label class="label" for="f_page_size">${tx('rm.page_size')}</label><input id="f_page_size" name="page_size" type="number" min="1" max="${MAX_PAGE}" value="${o.page_size}"></div>
|
|
255
|
+
<div class="field"><label class="label" for="f_enabled">${tx('rm.status')}</label><select id="f_enabled" name="enabled"><option value="1"${o.enabled ? raw(' selected') : ''}>${tx('rm.enabled')}</option><option value="0"${o.enabled ? '' : raw(' selected')}>${tx('rm.disabled_404')}</option></select></div>
|
|
251
256
|
</div>
|
|
252
257
|
${methodChecks(o.methods)}
|
|
253
258
|
${authChoice(o.auth)}
|
|
254
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
259
|
+
<div class="buttons"><button class="btn btn-hot">${tx('rm.save')}</button></div>
|
|
255
260
|
</form>
|
|
256
261
|
<form method="post" action="${PAGE}/${o.id}/delete" class="u-mt1">${csrf(s)}
|
|
257
|
-
<button class="btn btn-danger">${icon('trash')}
|
|
262
|
+
<button class="btn btn-danger">${icon('trash')} ${tx('ar.remove')}</button> <span class="muted small">${tx('ar.remove_help')}</span>
|
|
258
263
|
</form>`)}`;
|
|
259
|
-
return shell(s, `REST ${o.schema_name}.${o.object_name}`, [['
|
|
264
|
+
return shell(s, `REST ${o.schema_name}.${o.object_name}`, [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.rest'), PAGE], [`${o.schema_name}.${o.object_name}`]], main, 'sql');
|
|
260
265
|
}
|
|
261
266
|
|
|
262
267
|
const parseObject = (v: unknown): [string, string] | null => {
|
|
@@ -301,15 +306,15 @@ export async function autoRestBuilderRoutes(app: FastifyInstance) {
|
|
|
301
306
|
const rel = pick ? await findRel(s, pick[0], pick[1]) : undefined;
|
|
302
307
|
const again = async (problems: string[]) =>
|
|
303
308
|
send(reply.code(200), s, await listPage(s, { ...b, ...(rel ? { schema: rel.schema, name: rel.name } : {}) }, problems));
|
|
304
|
-
if (!rel) return again(['
|
|
309
|
+
if (!rel) return again([tr(s)('ar.e.choose_object')]);
|
|
305
310
|
const a = (await workspaceApps(s)).find((x) => String(x.id) === String(b.app_id ?? ''));
|
|
306
|
-
if (!a) return again(['
|
|
311
|
+
if (!a) return again([tr(s)('rm.e.choose_app')]);
|
|
307
312
|
const alias = String(b.alias ?? '').trim() || rel.name.toLowerCase().replace(/[^a-z0-9_-]+/g, '_').replace(/^[^a-z]+/, '').slice(0, 63);
|
|
308
313
|
const problems: string[] = [];
|
|
309
|
-
if (!ALIAS.test(alias)) problems.push(
|
|
310
|
-
const rp = await roleProblem(a.db_role, rel);
|
|
314
|
+
if (!ALIAS.test(alias)) problems.push(tr(s)('ar.e.url_name'));
|
|
315
|
+
const rp = await roleProblem(tr(s), a.db_role, rel);
|
|
311
316
|
if (rp) problems.push(rp);
|
|
312
|
-
const { input, problems: more } = await readInput(b, rel, a.db_role);
|
|
317
|
+
const { input, problems: more } = await readInput(tr(s), b, rel, a.db_role);
|
|
313
318
|
problems.push(...more);
|
|
314
319
|
if (problems.length) return again(problems);
|
|
315
320
|
try {
|
|
@@ -318,12 +323,12 @@ export async function autoRestBuilderRoutes(app: FastifyInstance) {
|
|
|
318
323
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9) returning id`,
|
|
319
324
|
[a.id, rel.schema, rel.name, alias, input.methods, input.auth, input.roles, input.page_size, s.username]);
|
|
320
325
|
await logActivity({ appId: a.id, username: s.username, event: 'autorest', ip: clientIp(req), detail: `enabled ${rel.schema}.${rel.name} as ${alias} (${input.methods.join(', ')}, ${input.auth})` });
|
|
321
|
-
flash(s, `${rel.schema}.${rel.name}
|
|
326
|
+
flash(s, tr(s)('ar.published_at', { object: `${rel.schema}.${rel.name}`, path: `/a/${a.alias}/autorest/${alias}/` }));
|
|
322
327
|
return back(reply, s, `${PAGE}/${r!.id}`);
|
|
323
328
|
} catch (e) {
|
|
324
329
|
const err = e as { code?: string; constraint?: string; message: string };
|
|
325
330
|
if (err.code !== '23505') throw e;
|
|
326
|
-
return again([/alias/.test(err.constraint ?? '') ?
|
|
331
|
+
return again([/alias/.test(err.constraint ?? '') ? tr(s)('ar.e.alias_taken', { app: a.name, alias }) : tr(s)('ar.e.already', { app: a.name, object: `${rel.schema}.${rel.name}` })]);
|
|
327
332
|
}
|
|
328
333
|
});
|
|
329
334
|
|
|
@@ -341,12 +346,12 @@ export async function autoRestBuilderRoutes(app: FastifyInstance) {
|
|
|
341
346
|
const o = await rowOr404(req, reply, s);
|
|
342
347
|
if (!o) return;
|
|
343
348
|
const kind = (await owner.one<{ k: string }>(`select c.relkind::text as k from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = $1 and c.relname = $2`, [o.schema_name, o.object_name]))?.k ?? 'r';
|
|
344
|
-
const { input, problems } = await readInput((req.body ?? {}) as Record<string, unknown>, { schema: o.schema_name, name: o.object_name, kind }, o.db_role);
|
|
349
|
+
const { input, problems } = await readInput(tr(s), (req.body ?? {}) as Record<string, unknown>, { schema: o.schema_name, name: o.object_name, kind }, o.db_role);
|
|
345
350
|
if (problems.length) return send(reply, s, await detailPage(s, o, problems));
|
|
346
351
|
await owner.query(`update meta.rest_object set methods = $2, auth = $3, roles = $4, page_size = $5, enabled = $6, updated_at = now() where id = $1`,
|
|
347
352
|
[o.id, input.methods, input.auth, input.roles, input.page_size, input.enabled]);
|
|
348
353
|
await logActivity({ appId: o.app_id, username: s.username, event: 'autorest', ip: clientIp(req), detail: `changed ${o.schema_name}.${o.object_name} (${input.methods.join(', ')}, ${input.auth}${input.enabled ? '' : ', disabled'})` });
|
|
349
|
-
flash(s, '
|
|
354
|
+
flash(s, tr(s)('rm.saved'));
|
|
350
355
|
return back(reply, s, `${PAGE}/${o.id}`);
|
|
351
356
|
});
|
|
352
357
|
|
|
@@ -357,7 +362,7 @@ export async function autoRestBuilderRoutes(app: FastifyInstance) {
|
|
|
357
362
|
if (!o) return;
|
|
358
363
|
await owner.query('delete from meta.rest_object where id = $1', [o.id]);
|
|
359
364
|
await logActivity({ appId: o.app_id, username: s.username, event: 'autorest', ip: clientIp(req), detail: `disabled ${o.schema_name}.${o.object_name}` });
|
|
360
|
-
flash(s, `${o.schema_name}.${o.object_name}
|
|
365
|
+
flash(s, tr(s)('ar.unpublished', { object: `${o.schema_name}.${o.object_name}` }));
|
|
361
366
|
return back(reply, s, PAGE);
|
|
362
367
|
});
|
|
363
368
|
}
|
package/src/builder/changelog.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { splitStatements } from '../binds.ts';
|
|
|
10
10
|
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
11
11
|
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
12
12
|
import { currentWorkspace, workshopDb } from './workspaces.ts';
|
|
13
|
+
import { tr, type BuilderKey } from './i18n.ts';
|
|
13
14
|
|
|
14
15
|
// (sprint 106, Database Actions DA-17; migration 118) SQL Workshop › Change Log: the change sets recorded from the
|
|
15
16
|
// worksheet, Table Properties and Schema Comparison (src/changelog.ts), per schema of the developer's workspace:
|
|
@@ -51,9 +52,10 @@ async function latestExports(s: Session) {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
const when = (t: string | null) => (t ? t.slice(0, 16).replace('T', ' ') : '');
|
|
54
|
-
const SOURCE_LABELS: Record<string,
|
|
55
|
+
const SOURCE_LABELS: Record<string, BuilderKey> = { worksheet: 'sql.tool.worksheet', table: 'cl.from.table', compare: 'sql.tool.compare' };
|
|
55
56
|
|
|
56
57
|
async function listPage(s: Session, q: Record<string, unknown>) {
|
|
58
|
+
const tx = tr(s);
|
|
57
59
|
const schemas = await schemasWithChanges(s);
|
|
58
60
|
const schema = schemas.some((x) => x.schema_name === q.schema) ? String(q.schema) : (schemas[0]?.schema_name ?? '');
|
|
59
61
|
const exportsList = await latestExports(s);
|
|
@@ -62,55 +64,55 @@ async function listPage(s: Session, q: Record<string, unknown>) {
|
|
|
62
64
|
const inExport = (c: ChangeSet) => !!exp && c.created_at <= exp.at;
|
|
63
65
|
const pending = list.filter((c) => !c.exported_at).length;
|
|
64
66
|
const nav = schemas.length
|
|
65
|
-
? html`<nav class="settings-tabs" aria-label="
|
|
67
|
+
? html`<nav class="settings-tabs" aria-label="${tx('cl.schemas')}">${schemas.map((x) => html`<a href="${CHANGES}?${new URLSearchParams({ schema: x.schema_name, ...(exp ? { app: String(exp.id) } : {}) })}"${x.schema_name === schema ? raw(' aria-current="page"') : ''}>${x.schema_name} <span class="muted small">${x.n}</span></a>`)}</nav>`
|
|
66
68
|
: '';
|
|
67
69
|
const table = list.length
|
|
68
|
-
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>#</th><th
|
|
70
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>#</th><th>${tx('cl.col.change_set')}</th><th>${tx('cl.col.author')}</th><th>${tx('cl.col.recorded')}</th><th>${tx('cl.col.from')}</th><th>${tx('cl.col.latest_export')}</th><th>${tx('cl.col.migration_file')}</th></tr></thead><tbody>
|
|
69
71
|
${list.map((c) => html`<tr>
|
|
70
72
|
<td data-label="#">${c.seq}</td>
|
|
71
|
-
<td data-label="
|
|
73
|
+
<td data-label="${tx('cl.col.change_set')}" class="wrap-anywhere"><details><summary><code>${flywayName(c)}</code>${c.object_name ? html` <span class="muted small">${c.object_name}</span>` : ''}</summary>
|
|
72
74
|
<pre class="source wrap-pre" data-code-view="sql" tabindex="0" aria-label="SQL">${c.sql}</pre>
|
|
73
75
|
<form method="post" action="${CHANGES}/${c.id}" class="search u-mwnone">${csrf(s)}
|
|
74
|
-
<div class="field"><label class="label" for="f_name_${c.id}"
|
|
75
|
-
<button class="btn btn-sm u-selfend"
|
|
76
|
+
<div class="field"><label class="label" for="f_name_${c.id}">${tx('scr.name')}</label><input id="f_name_${c.id}" name="name" value="${c.name}" pattern="[a-z0-9][a-z0-9_]{0,59}" required></div>
|
|
77
|
+
<button class="btn btn-sm u-selfend">${tx('cl.rename')}</button></form>
|
|
76
78
|
<div class="buttons">
|
|
77
79
|
<form method="post" action="${CHANGES}/${c.id}/download" class="u-inline">${csrf(s)}<button class="btn btn-sm">${icon('download')} ${flywayName(c)}</button></form>
|
|
78
|
-
<form method="post" action="${CHANGES}/${c.id}/delete" class="u-inline">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="
|
|
80
|
+
<form method="post" action="${CHANGES}/${c.id}/delete" class="u-inline">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="${tx('cl.delete_confirm', { n: c.seq })}">${icon('trash')} ${tx('scr.delete')}</button></form>
|
|
79
81
|
</div></details></td>
|
|
80
|
-
<td data-label="
|
|
81
|
-
<td data-label="
|
|
82
|
-
<td data-label="
|
|
83
|
-
<td data-label="
|
|
84
|
-
<td data-label="
|
|
82
|
+
<td data-label="${tx('cl.col.author')}">${c.author}</td>
|
|
83
|
+
<td data-label="${tx('cl.col.recorded')}">${when(c.created_at)}</td>
|
|
84
|
+
<td data-label="${tx('cl.col.from')}">${SOURCE_LABELS[c.source] ? tx(SOURCE_LABELS[c.source]) : c.source}</td>
|
|
85
|
+
<td data-label="${tx('cl.col.latest_export')}">${exp ? (inExport(c) ? html`<span class="tag">✓ ${tx('cl.in_it')}</span>` : html`<span class="tag tag-warn">${tx('cl.after_it')}</span>`) : html`<span class="muted">—</span>`}</td>
|
|
86
|
+
<td data-label="${tx('cl.col.migration_file')}">${c.exported_at ? tx('cl.written', { time: when(c.exported_at) }) : html`<span class="muted">${tx('cl.not_yet')}</span>`}</td></tr>`)}
|
|
85
87
|
</tbody></table></div>`
|
|
86
|
-
: html`<p class="muted"
|
|
88
|
+
: html`<p class="muted">${tx('cl.none_yet')}</p>`;
|
|
87
89
|
const exportPick = exportsList.length
|
|
88
90
|
? html`<form method="get" action="${CHANGES}" class="search u-mwnone u-mb1"><input type="hidden" name="schema" value="${schema}">
|
|
89
|
-
<div class="field"><label class="label" for="f_app"
|
|
91
|
+
<div class="field"><label class="label" for="f_app">${tx('cl.compare_with')}</label><select id="f_app" name="app">${exportsList.map((e) =>
|
|
90
92
|
html`<option value="${e.id}"${e === exp ? raw(' selected') : ''}>${e.name} (${when(e.at)})</option>`)}</select></div>
|
|
91
|
-
<button class="btn u-selfend"
|
|
92
|
-
<p class="muted small u-mt0"
|
|
93
|
-
: html`<p class="muted small"
|
|
93
|
+
<button class="btn u-selfend">${tx('cl.show')}</button></form>
|
|
94
|
+
<p class="muted small u-mt0">${tx.rich('cl.export_note', { after: html`<em>${tx('cl.after_it_title')}</em>` })}</p>`
|
|
95
|
+
: html`<p class="muted small">${tx('cl.no_exports')}</p>`;
|
|
94
96
|
const downloads = list.length
|
|
95
97
|
? html`<form method="post" action="${CHANGES}/download" class="u-mt1">${csrf(s)}<input type="hidden" name="schema" value="${schema}">
|
|
96
98
|
<div class="form-grid">
|
|
97
|
-
<div class="field"><label class="label" for="f_format"
|
|
98
|
-
<option value="flyway"
|
|
99
|
-
<option value="liquibase"
|
|
100
|
-
<option value="sql"
|
|
101
|
-
<div class="field"><label class="label" for="f_which"
|
|
102
|
-
<option value="pending"
|
|
103
|
-
<option value="all"
|
|
104
|
-
${exp ? html`<option value="after_export"
|
|
99
|
+
<div class="field"><label class="label" for="f_format">${tx('cl.format')}</label><select id="f_format" name="format">
|
|
100
|
+
<option value="flyway">${tx('cl.format.flyway')}</option>
|
|
101
|
+
<option value="liquibase">${tx('cl.format.liquibase')}</option>
|
|
102
|
+
<option value="sql">${tx('cl.format.sql')}</option></select></div>
|
|
103
|
+
<div class="field"><label class="label" for="f_which">${tx('cl.change_sets')}</label><select id="f_which" name="which">
|
|
104
|
+
<option value="pending">${tx('cl.which.pending', { n: pending })}</option>
|
|
105
|
+
<option value="all">${tx('cl.which.all', { schema, n: list.length })}</option>
|
|
106
|
+
${exp ? html`<option value="after_export">${tx('cl.which.after', { app: exp.name, n: list.filter((c) => !inExport(c)).length })}</option>` : ''}</select></div>
|
|
105
107
|
</div>
|
|
106
108
|
${exp ? html`<input type="hidden" name="app" value="${exp.id}">` : ''}
|
|
107
|
-
<div class="buttons"><button class="btn btn-hot">${icon('download')}
|
|
109
|
+
<div class="buttons"><button class="btn btn-hot">${icon('download')} ${tx('cl.download')}</button></div></form>`
|
|
108
110
|
: '';
|
|
109
|
-
const main = html`<h1 class="u-mb1"
|
|
110
|
-
<p class="muted"
|
|
111
|
+
const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('changes', s)}
|
|
112
|
+
<p class="muted">${tx('cl.intro')}</p>
|
|
111
113
|
${nav}
|
|
112
|
-
${schema ? region(
|
|
113
|
-
return shell(s, '
|
|
114
|
+
${schema ? region(tx('cl.change_sets_of', { schema }), html`${exportPick}${table}${downloads}`) : region(tx('cl.change_sets'), table)}`;
|
|
115
|
+
return shell(s, tx('sql.tool.changes'), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.changes')]], main, 'sql');
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
async function oneOr404(req: Req, reply: FastifyReply, s: Session) {
|
|
@@ -153,7 +155,7 @@ export async function changeLogRoutes(app: FastifyInstance) {
|
|
|
153
155
|
if (exp) list = list.filter((c) => c.created_at > exp.at);
|
|
154
156
|
}
|
|
155
157
|
if (!list.length) {
|
|
156
|
-
flash(s, '
|
|
158
|
+
flash(s, tr(s)('cl.nothing_to_write'), 'error');
|
|
157
159
|
return back(reply, s, `${CHANGES}?${new URLSearchParams({ schema })}`);
|
|
158
160
|
}
|
|
159
161
|
const format = asFormat(b.format);
|
|
@@ -178,10 +180,10 @@ export async function changeLogRoutes(app: FastifyInstance) {
|
|
|
178
180
|
const c = await oneOr404(req, reply, s);
|
|
179
181
|
if (!c) return;
|
|
180
182
|
const name = String(req.body?.name ?? '').trim().toLowerCase();
|
|
181
|
-
if (!NAME.test(name)) flash(s,
|
|
183
|
+
if (!NAME.test(name)) flash(s, tr(s)('cl.bad_name'), 'error');
|
|
182
184
|
else {
|
|
183
185
|
await owner.query('update meta.change_set set name = $2 where id = $1', [c.id, name]);
|
|
184
|
-
flash(s,
|
|
186
|
+
flash(s, tr(s)('cl.renamed', { n: c.seq, file: flywayName({ seq: c.seq, name }) }));
|
|
185
187
|
}
|
|
186
188
|
return back(reply, s, `${CHANGES}?${new URLSearchParams({ schema: c.schema_name })}`);
|
|
187
189
|
});
|
|
@@ -193,7 +195,7 @@ export async function changeLogRoutes(app: FastifyInstance) {
|
|
|
193
195
|
if (!c) return;
|
|
194
196
|
await owner.query('delete from meta.change_set where id = $1', [c.id]);
|
|
195
197
|
await logActivity({ username: s.username, event: 'change_log_delete', ip: clientIp(req), detail: `${c.schema_name} #${c.seq} ${c.name}` });
|
|
196
|
-
flash(s,
|
|
198
|
+
flash(s, tr(s)('cl.deleted', { n: c.seq }));
|
|
197
199
|
return back(reply, s, `${CHANGES}?${new URLSearchParams({ schema: c.schema_name })}`);
|
|
198
200
|
});
|
|
199
201
|
|
|
@@ -212,7 +214,7 @@ export async function changeLogRoutes(app: FastifyInstance) {
|
|
|
212
214
|
const statements = splitStatements(script);
|
|
213
215
|
const back2 = `${BASE}/sql/compare?${new URLSearchParams({ source, target })}`;
|
|
214
216
|
if (!statements.length) {
|
|
215
|
-
flash(s,
|
|
217
|
+
flash(s, tr(s)('cmp.nothing', { source, target }));
|
|
216
218
|
return back(reply, s, back2);
|
|
217
219
|
}
|
|
218
220
|
const c = await db.pool.connect();
|
|
@@ -230,11 +232,11 @@ export async function changeLogRoutes(app: FastifyInstance) {
|
|
|
230
232
|
}
|
|
231
233
|
await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), detail: `compare apply ${source} → ${target}: ${failed ? `failed: ${failed}` : `${statements.length} statement(s)`}`.slice(0, 2000) });
|
|
232
234
|
if (failed) {
|
|
233
|
-
flash(s,
|
|
235
|
+
flash(s, tr(s)('cmp.failed', { error: failed }), 'error');
|
|
234
236
|
return back(reply, s, back2);
|
|
235
237
|
}
|
|
236
238
|
await recordChanges({ workspaceId: currentWorkspace(s), author: s.username ?? '?', source: 'compare', statements, defaultSchema: target, name: `like_${source}`.toLowerCase().replace(/[^a-z0-9_]+/g, '_').slice(0, 60) });
|
|
237
|
-
flash(s,
|
|
239
|
+
flash(s, tr(s)('cmp.applied', { source, target, n: statements.length }));
|
|
238
240
|
return back(reply, s, `${CHANGES}?${new URLSearchParams({ schema: target })}`);
|
|
239
241
|
});
|
|
240
242
|
}
|
|
@@ -33,7 +33,7 @@ const CHECKS: Record<string, SqlShape> = {
|
|
|
33
33
|
'process.code': 'statements', 'authz_scheme.value': 'boolean',
|
|
34
34
|
'lov.query': 'select', 'app_process.code': 'statements', 'automation.query': 'select', 'automation_action.code': 'statements', 'automation_action.condition': 'boolean',
|
|
35
35
|
'document_template.query': 'select', 'report_query.query': 'select', 'task_definition.action_code': 'statements', 'dynamic_action.code': 'statements',
|
|
36
|
-
'button.badge_query': 'select', 'list.query': 'select', 'list_entry.condition': 'boolean',
|
|
36
|
+
'button.badge_query': 'select', 'list.query': 'select', 'list_entry.condition': 'boolean', 'list_entry.badge_query': 'select',
|
|
37
37
|
};
|
|
38
38
|
/** (147) The expression fields of server-side conditions (their type select names how to read them). */
|
|
39
39
|
const CONDITION_EXPRS = ['computation', 'app_computation', 'branch', 'process', 'item', 'validation'].map((k) => `${k}.condition_expr`).concat('dynamic_action.server_condition_expr');
|