pgkiln 0.81.0 → 0.83.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 (109) hide show
  1. package/db/migrations/147_condition_types.sql +80 -0
  2. package/db/migrations/148_validation_types.sql +44 -0
  3. package/db/migrations/149_dynamic_action_events.sql +45 -0
  4. package/db/migrations/150_process_types.sql +23 -0
  5. package/db/migrations/151_item_types.sql +16 -0
  6. package/db/migrations/152_workflow_diagram_region.sql +10 -0
  7. package/db/migrations/153_component_events.sql +21 -0
  8. package/docs/apex-feature-parity.md +11 -9
  9. package/docs/guide/02-concepts.md +3 -3
  10. package/docs/guide/03-builder.md +8 -1
  11. package/docs/guide/04-pages-and-regions.md +19 -4
  12. package/docs/guide/05-items.md +5 -0
  13. package/docs/guide/06-processing.md +104 -17
  14. package/docs/guide/07-dynamic-actions.md +43 -1
  15. package/docs/guide/11-from-apex.md +8 -0
  16. package/docs/guide/12-development.md +9 -1
  17. package/examples/hr/hr_79_condition_types.sql +17 -0
  18. package/package.json +1 -1
  19. package/public/app.css +14 -0
  20. package/public/app.js +242 -13
  21. package/public/builder.css +25 -2
  22. package/public/data-modeler.js +8 -5
  23. package/public/sql-monitor.js +5 -2
  24. package/src/appfiles.ts +4 -4
  25. package/src/builder/advisor.ts +43 -7
  26. package/src/builder/ai-builder.ts +50 -46
  27. package/src/builder/api.ts +49 -49
  28. package/src/builder/autorest.ts +92 -87
  29. package/src/builder/changelog.ts +39 -37
  30. package/src/builder/code-editor.ts +14 -10
  31. package/src/builder/components.ts +54 -23
  32. package/src/builder/conditions.ts +64 -0
  33. package/src/builder/data-modeler.ts +89 -84
  34. package/src/builder/data-security.ts +39 -37
  35. package/src/builder/dataload.ts +69 -65
  36. package/src/builder/datapump.ts +108 -97
  37. package/src/builder/ddl-tools.ts +38 -34
  38. package/src/builder/designer.ts +9 -1
  39. package/src/builder/document-view.ts +11 -11
  40. package/src/builder/i18n/de.ts +1662 -0
  41. package/src/builder/i18n/en.ts +1662 -0
  42. package/src/builder/i18n/es.ts +1662 -0
  43. package/src/builder/i18n/fr.ts +1662 -0
  44. package/src/builder/i18n/it.ts +1662 -0
  45. package/src/builder/i18n/ja.ts +1662 -0
  46. package/src/builder/i18n/ko.ts +1662 -0
  47. package/src/builder/i18n/nl.ts +1662 -0
  48. package/src/builder/i18n/pt.ts +1662 -0
  49. package/src/builder/i18n/zh-cn.ts +1662 -0
  50. package/src/builder/i18n/zh-tw.ts +1662 -0
  51. package/src/builder/i18n.ts +18 -0
  52. package/src/builder/object-actions.ts +18 -16
  53. package/src/builder/object-browser.ts +2 -2
  54. package/src/builder/querybuilder.ts +42 -36
  55. package/src/builder/quicksql.ts +16 -18
  56. package/src/builder/region-help.ts +3 -1
  57. package/src/builder/region-settings.ts +225 -198
  58. package/src/builder/report-settings.ts +64 -60
  59. package/src/builder/rest-modules.ts +145 -143
  60. package/src/builder/sampledata.ts +59 -52
  61. package/src/builder/scheduling.ts +180 -159
  62. package/src/builder/scripts.ts +71 -65
  63. package/src/builder/sql-charts.ts +36 -30
  64. package/src/builder/sql-monitor.ts +95 -89
  65. package/src/builder/sql-utilities.ts +47 -40
  66. package/src/builder/top-sql.ts +21 -17
  67. package/src/builder/unload.ts +48 -44
  68. package/src/builder/worksheet.ts +3 -3
  69. package/src/cron-schedule.ts +47 -17
  70. package/src/i18n/ar.ts +5 -0
  71. package/src/i18n/cs.ts +5 -0
  72. package/src/i18n/da.ts +5 -0
  73. package/src/i18n/de.ts +5 -0
  74. package/src/i18n/el.ts +5 -0
  75. package/src/i18n/es.ts +5 -0
  76. package/src/i18n/fi.ts +5 -0
  77. package/src/i18n/fr.ts +5 -0
  78. package/src/i18n/he.ts +5 -0
  79. package/src/i18n/it.ts +5 -0
  80. package/src/i18n/ja.ts +5 -0
  81. package/src/i18n/ko.ts +5 -0
  82. package/src/i18n/nb.ts +5 -0
  83. package/src/i18n/pl.ts +5 -0
  84. package/src/i18n/pt.ts +5 -0
  85. package/src/i18n/ru.ts +5 -0
  86. package/src/i18n/sv.ts +5 -0
  87. package/src/i18n/tr.ts +5 -0
  88. package/src/i18n/uk.ts +5 -0
  89. package/src/i18n/zh.ts +5 -0
  90. package/src/i18n.ts +10 -0
  91. package/src/metadata.ts +74 -9
  92. package/src/runtime/authz.ts +9 -5
  93. package/src/runtime/calendar.ts +3 -2
  94. package/src/runtime/context.ts +6 -0
  95. package/src/runtime/da-styles.ts +37 -0
  96. package/src/runtime/documents.ts +3 -2
  97. package/src/runtime/engine.ts +95 -13
  98. package/src/runtime/grid.ts +1 -0
  99. package/src/runtime/items.ts +33 -0
  100. package/src/runtime/logic.ts +79 -8
  101. package/src/runtime/maps.ts +56 -7
  102. package/src/runtime/process-types.ts +139 -0
  103. package/src/runtime/processes.ts +2 -0
  104. package/src/runtime/regions.ts +4 -0
  105. package/src/runtime/render.ts +19 -3
  106. package/src/runtime/routes.ts +6 -2
  107. package/src/runtime/tree.ts +4 -3
  108. package/src/runtime/workflow-diagram.ts +45 -0
  109. package/src/workflow.ts +10 -2
@@ -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 kindLabel = (k: string) => RELKINDS[k] ?? k;
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 'The application has no database role. AutoREST runs as the application\'s role: set one in its settings.';
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 `The application's database role: ${problem}`;
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 `"${role}" owns ${rel.schema}.${rel.name} (or is a member of its owner): grants and row level security would not apply. Publish it through an application whose role does not own it.`;
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('Choose at least one method.');
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('Choose who may call it: a token of the application, or public.');
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(`The page size is 1 to ${MAX_PAGE}.`);
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('A materialized view answers GET only.');
105
- if (!keyColumns(cols).length && methods.some((m) => m === 'PUT' || m === 'DELETE')) problems.push('PUT and DELETE need a primary key; this object has none.');
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('Role names are at most 100 characters.');
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>Methods</legend><div class="form-grid">${AUTOREST_METHODS.map((m) =>
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
- { GET: 'rows and one row', POST: 'insert a row', PUT: 'change a row', DELETE: 'delete a row' }[m]}</small></label>`)}</div></fieldset>`;
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>Who may call it</legend>
118
- <label class="check"><input type="radio" name="auth" value="token"${auth !== 'public' ? raw(' checked') : ''}> A bearer token of the application <small class="help">(an account's token or an OAuth client, App › REST API)</small></label>
119
- <label class="check"><input type="radio" name="auth" value="public"${auth === 'public' ? raw(' checked') : ''}> Public: anyone, without a token <small class="help">(runs as <code>nobody</code>; grants and row level security still apply)</small></label>
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
- async function listPage(s: Session, q: Record<string, unknown>, problems: string[] = []) {
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>Object</th><th>URL</th><th>Application</th><th>Methods</th><th>Access</th></tr></thead><tbody>
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="Object"><a href="${PAGE}/${o.id}">${o.schema_name}.${o.object_name}</a>${o.enabled ? '' : html` <span class="tag">off</span>`}</td>
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="Application">${o.app_name}</td>
139
- <td data-label="Methods">${o.methods.join(', ')}</td>
140
- <td data-label="Access">${o.auth === 'public' ? html`<strong>public</strong>` : 'token'}${o.roles.length ? `, role ${o.roles.join(' or ')}` : ''}</td></tr>`)}
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">No tables or views are published yet. Enable one below, or with <em>REST › Enable</em> in an object's ⋯ menu.</p>`;
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">Table or view</label>
147
- <select id="f_object" name="object" required><option value="">- choose -</option>${[...bySchema].map(([schema, rs]) =>
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">Application</label>
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}, runs as ${a.db_role})</option>`)}</select>
154
- <small class="help">Its database role runs the statements, its tokens and roles decide who may call it.</small></div>
155
- <div class="field"><label class="label" for="f_alias">URL name</label>
156
- <input id="f_alias" name="alias" value="${String(q.alias ?? '')}" placeholder="the object's name">
157
- <small class="help">/a/&lt;app&gt;/autorest/<b>name</b>/ — lower case letters, digits, _ and -</small></div>
158
- <div class="field"><label class="label" for="f_roles">Roles</label>
159
- <input id="f_roles" name="roles" value="${String(q.roles ?? '')}" placeholder="any caller">
160
- <small class="help">Comma separated: the caller needs one of these application roles.</small></div>
161
- <div class="field"><label class="label" for="f_page_size">Page size</label>
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/&lt;app&gt;/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">Rows per page without ?limit= (at most ${MAX_PAGE}).</small></div>
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')} Enable</button></div>
168
- ${usable.length ? '' : html`<p class="muted">No application of this workspace has a database role to run as.</p>`}
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">SQL Workshop</h1>${workshopTabs('rest', s)}${restNav('autorest')}
171
- <p class="muted">RESTful Services: publish a table or view as REST endpoints (AutoREST). Rows come paged as JSON, with filters; a primary key gives single-row GET, PUT and DELETE. Requests run as the publishing application's database role with the caller as <code>meta.app_user()</code>, so its grants and row level security apply. For tailored endpoints with your own SQL or PL/pgSQL, create a <a href="${BASE}/sql/rest/modules">module</a>.</p>
172
- ${region('Published objects', table)}
173
- ${region('Enable a table or view', form)}`;
174
- return shell(s, 'RESTful Services', [['SQL Workshop', `${BASE}/sql`], ['RESTful Services']], main, 'sql');
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
- async function detailPage(s: Session, o: Row, problems: string[] = []) {
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) : 'The object no longer exists: its endpoints answer 404.';
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 ?? html`Runs as <code>${role}</code>: not the owner, no superuser, no row level security bypass`)}
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}: <code>${role}</code> may ${priv[m]}`
201
- : html`${m}: <code>${role}</code> may not ${priv[m]} — <code>grant ${priv[m]} on ${qualified(o)} to ${role};</code>`)) : ''}
202
- ${info && (info.kind === 'r' || info.kind === 'p') ? check(info.rls ? true : null, info.rls ? `Row level security is on (${info.policies} polic${info.policies === 1 ? 'y' : 'ies'})` : 'Row level security is off: callers see every row the role may select') : ''}
203
- ${info && info.kind === 'v' ? check(info.invoker, info.invoker ? 'The view is security_invoker: the tables\' row level security applies to the caller' : html`The view runs with its owner's rights, so the row level security of the tables beneath does not apply: <code>alter view ${qualified(o)} set (security_invoker = true);</code>`) : ''}
204
- ${info ? check(key.length ? true : null, key.length ? html`Primary key ${key.map((k) => k.name).join(', ')}: single rows at <code>…/${o.alias}/&lt;${key.map((k) => k.name).join(',')}&gt;</code>` : 'No primary key: the collection only (GET and POST)') : ''}
205
- ${cols.some((c) => !c.readable) ? check(null, `Columns the role may not select are left out: ${cols.filter((c) => !c.readable).map((c) => c.name).join(', ')}`) : ''}
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}/&lt;${key.map((k) => k.name).join(',')}&gt;</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, `Rows, paged: ?limit= (1–${MAX_PAGE}, default ${o.page_size}) &offset= ; ?<column>=<value> ; ?q=<text> ; ?order=<column>.desc`]);
211
- if (o.methods.includes('POST')) endpoints.push(['POST', base, 'A JSON object of column values: the new row (201)']);
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}`, 'One row (404 when there is none)']);
214
- if (o.methods.includes('PUT')) endpoints.push(['PUT', `${base}${keyEx}`, 'Change the columns the JSON object names']);
215
- if (o.methods.includes('DELETE')) endpoints.push(['DELETE', `${base}${keyEx}`, 'Delete the row (204)']);
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">SQL Workshop</h1>${workshopTabs('rest', s)}
231
- <h2 class="u-mb1 wrap-anywhere">${o.schema_name}.${o.object_name} <span class="muted small">${info ? kindLabel(info.kind) : ''} · ${o.enabled ? 'enabled' : 'disabled'}</span></h2>
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">Disabled: the endpoints answer 404.</div>` : ''}
238
+ ${!o.enabled ? html`<div class="alert alert-info" role="status">${tx('ar.disabled_alert')}</div>` : ''}
234
239
  <div class="columns">
235
- ${region('Checks', checks)}
236
- ${region('Endpoints', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Method</th><th>URL</th><th>Does</th></tr></thead><tbody>
237
- ${endpoints.map(([m, u, d]) => html`<tr><td data-label="Method"><code>${m}</code></td><td data-label="URL" class="wrap-anywhere"><code>${u}</code></td><td data-label="Does">${d}</td></tr>`)}</tbody></table></div>
238
- <p class="wrap-anywhere">OpenAPI: <a href="${openapi}" target="_blank" rel="noopener"><code>${openapi}</code></a> <span class="muted small">(the public objects; send a token for all of them)</span></p>`)}
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('Try it', html`${o.auth === 'public'
241
- ? html`<p class="muted u-mt0">Public: no token needed.</p>`
242
- : html`<p class="muted u-mt0">Get <code>$TOKEN</code> from <a href="${BASE}/apps/${o.app_id}/api">${o.app_name} › REST API</a>: issue a token for an account, or use an OAuth client's access token.${o.roles.length ? ` The caller needs the role ${o.roles.join(' or ')}.` : ''} Without a valid token the endpoints answer 401; without the role or a grant, 403.</p>`}
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('Settings', html`<form method="post" action="${PAGE}/${o.id}">${csrf(s)}
246
- <p class="muted u-mt0 wrap-anywhere">Published by <a href="${BASE}/apps/${o.app_id}">${o.app_name}</a> (runs as <code>${role ?? '—'}</code>) at <code>/a/${o.app_alias}/autorest/${o.alias}/</code>.</p>
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">Roles</label><input id="f_roles" name="roles" value="${o.roles.join(', ')}" placeholder="any caller"><small class="help">Comma separated: the caller needs one of these application roles.</small></div>
249
- <div class="field"><label class="label" for="f_page_size">Page size</label><input id="f_page_size" name="page_size" type="number" min="1" max="${MAX_PAGE}" value="${o.page_size}"></div>
250
- <div class="field"><label class="label" for="f_enabled">Status</label><select id="f_enabled" name="enabled"><option value="1"${o.enabled ? raw(' selected') : ''}>Enabled</option><option value="0"${o.enabled ? '' : raw(' selected')}>Disabled (404)</option></select></div>
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">Save</button></div>
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')} Disable REST (remove)</button> <span class="muted small">The endpoints answer 404 at once; the table itself is not changed.</span>
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}`, [['SQL Workshop', `${BASE}/sql`], ['RESTful Services', PAGE], [`${o.schema_name}.${o.object_name}`]], main, 'sql');
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(['Choose a table or view of a schema the SQL Workshop may use.']);
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(['Choose an application of this workspace.']);
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('The URL name starts with a lower case letter: letters, digits, _ and - (at most 63).');
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} is published at /a/${a.alias}/autorest/${alias}/.`);
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 ?? '') ? `${a.name} already publishes something as ${alias}: choose another URL name.` : `${a.name} already publishes ${rel.schema}.${rel.name}.`]);
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, 'Saved.');
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} is no longer published.`);
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
  }
@@ -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, string> = { worksheet: 'SQL Worksheet', table: 'Table Properties', compare: 'Schema Comparison' };
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="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>`
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>Change set</th><th>Author</th><th>Recorded</th><th>From</th><th>Latest app export</th><th>Migration file</th></tr></thead><tbody>
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="Change set" class="wrap-anywhere"><details><summary><code>${flywayName(c)}</code>${c.object_name ? html` <span class="muted small">${c.object_name}</span>` : ''}</summary>
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}">Name</label><input id="f_name_${c.id}" name="name" value="${c.name}" pattern="[a-z0-9][a-z0-9_]{0,59}" required></div>
75
- <button class="btn btn-sm u-selfend">Rename</button></form>
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="Delete change set ${c.seq} from the log? The database is not changed, and its number is not given again.">${icon('trash')} Delete</button></form>
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="Author">${c.author}</td>
81
- <td data-label="Recorded">${when(c.created_at)}</td>
82
- <td data-label="From">${SOURCE_LABELS[c.source] ?? c.source}</td>
83
- <td data-label="Latest app export">${exp ? (inExport(c) ? html`<span class="tag">✓ in it</span>` : html`<span class="tag tag-warn">after it</span>`) : html`<span class="muted">—</span>`}</td>
84
- <td data-label="Migration file">${c.exported_at ? `written ${when(c.exported_at)}` : html`<span class="muted">not yet</span>`}</td></tr>`)}
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">No change sets yet. DDL you run in the SQL Worksheet, apply in Table Properties or apply from Schema Comparison is recorded here.</p>`;
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">Compare with the latest export of</label><select id="f_app" name="app">${exportsList.map((e) =>
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">Show</button></form>
92
- <p class="muted small u-mt0">✓ in it: recorded before the application was last exported, so a deployment of that export expects the change. <em>After it</em>: still to be shipped with the next export.</p>`
93
- : html`<p class="muted small">No application of this workspace was exported from the builder yet (Application › Export): the change sets can't be compared with an export.</p>`;
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">Format</label><select id="f_format" name="format">
98
- <option value="flyway">Flyway: V0001__name.sql files (a zip for several)</option>
99
- <option value="liquibase">Liquibase: formatted SQL changelog</option>
100
- <option value="sql">Plain SQL script</option></select></div>
101
- <div class="field"><label class="label" for="f_which">Change sets</label><select id="f_which" name="which">
102
- <option value="pending">Not yet written to a file (${pending})</option>
103
- <option value="all">All of ${schema} (${list.length})</option>
104
- ${exp ? html`<option value="after_export">After the latest export of ${exp.name} (${list.filter((c) => !inExport(c)).length})</option>` : ''}</select></div>
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')} Download migration</button></div></form>`
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">SQL Workshop</h1>${workshopTabs('changes', s)}
110
- <p class="muted">The change log (Liquibase, Flyway): DDL run from the SQL Worksheet, Table Properties and Schema Comparison is kept as change sets per schema, with who ran it and when. Download them as a migration for another database: Flyway versioned files, a Liquibase formatted SQL changelog or a plain script. Only statements that succeeded are kept; data changes (INSERT, UPDATE, DELETE) are not.</p>
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(`Change sets of ${schema}`, html`${exportPick}${table}${downloads}`) : region('Change sets', table)}`;
113
- return shell(s, 'Change Log', [['SQL Workshop', `${BASE}/sql`], ['Change Log']], main, 'sql');
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, 'No change sets to write: every one is in an earlier file. Choose "All".', 'error');
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, 'A name is lower case letters, digits and _ (at most 60), starting with a letter or digit.', 'error');
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, `Change set ${c.seq} is now ${flywayName({ seq: c.seq, name })}.`);
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, `Change set ${c.seq} removed from the log; the database is unchanged.`);
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, `${target} is already like ${source}: nothing to apply.`);
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, `Nothing was changed: the change script ran in one transaction, which was rolled back. ${failed}`, 'error');
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, `${target} is now like ${source}: ${statements.length} statement(s), recorded in the change log.`);
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
  }