pgkiln 0.35.0 → 0.37.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/SECURITY.md +11 -9
- package/db/migrations/087_login_page.sql +175 -0
- package/db/migrations/088_region_templates.sql +18 -0
- package/db/migrations/089_page_groups.sql +330 -0
- package/db/migrations/090_workspace_isolation.sql +512 -0
- package/docs/apex-feature-parity.md +23 -21
- package/docs/guide/03-builder.md +85 -8
- package/docs/guide/04-pages-and-regions.md +42 -3
- package/docs/guide/08-security.md +46 -5
- package/docs/guide/11-from-apex.md +4 -1
- package/docs/guide/12-development.md +13 -3
- package/docs/guide/17-mobile.md +3 -1
- package/docs/guide/18-cli.md +2 -0
- package/docs/guide/20-ai-agents.md +2 -0
- package/examples/hr/hr_40_sample_data.sql +1 -1
- package/examples/hr/hr_54_region_templates.sql +67 -0
- package/examples/hr/hr_55_page_groups.sql +24 -0
- package/package.json +1 -1
- package/public/app.css +60 -3
- package/public/app.js +89 -3
- package/public/builder.css +11 -0
- package/public/builder.js +50 -0
- package/public/code-editor.css +3 -3
- package/public/sw.js +5 -3
- package/src/app.ts +20 -0
- package/src/appfiles.ts +1 -0
- package/src/automations.ts +3 -2
- package/src/builder/advisor.ts +25 -5
- package/src/builder/ai-builder.ts +27 -18
- package/src/builder/blueprints.ts +7 -7
- package/src/builder/code-editor.ts +3 -2
- package/src/builder/components.ts +15 -2
- package/src/builder/dataload.ts +24 -14
- package/src/builder/ddl-tools.ts +117 -0
- package/src/builder/designer.ts +29 -3
- package/src/builder/documents.ts +2 -1
- package/src/builder/forms.ts +6 -4
- package/src/builder/home.ts +6 -2
- package/src/builder/login-page.ts +82 -0
- package/src/builder/newapp.ts +21 -3
- package/src/builder/quicksql.ts +3 -2
- package/src/builder/region-help.ts +4 -2
- package/src/builder/region-settings.ts +19 -3
- package/src/builder/report-settings.ts +4 -2
- package/src/builder/routes.ts +18 -6
- package/src/builder/sampledata.ts +22 -18
- package/src/builder/scripts.ts +27 -16
- package/src/builder/shared.ts +1 -1
- package/src/builder/sql.ts +18 -10
- package/src/builder/ui.ts +2 -2
- package/src/builder/unload.ts +3 -2
- package/src/builder/workspaces.ts +175 -15
- package/src/cli/main.ts +45 -0
- package/src/cli/mcp.ts +42 -0
- package/src/cli/replace.ts +4 -0
- package/src/db.ts +36 -6
- package/src/ddl.ts +510 -0
- package/src/debug.ts +3 -2
- package/src/i18n/ar.ts +17 -0
- package/src/i18n/cs.ts +17 -0
- package/src/i18n/da.ts +17 -0
- package/src/i18n/de.ts +17 -0
- package/src/i18n/el.ts +17 -0
- package/src/i18n/es.ts +17 -0
- package/src/i18n/fi.ts +17 -0
- package/src/i18n/fr.ts +17 -0
- package/src/i18n/he.ts +17 -0
- package/src/i18n/it.ts +17 -0
- package/src/i18n/ja.ts +17 -0
- package/src/i18n/ko.ts +17 -0
- package/src/i18n/nb.ts +17 -0
- package/src/i18n/pl.ts +17 -0
- package/src/i18n/pt.ts +17 -0
- package/src/i18n/ru.ts +17 -0
- package/src/i18n/sv.ts +17 -0
- package/src/i18n/tr.ts +17 -0
- package/src/i18n/uk.ts +17 -0
- package/src/i18n/zh.ts +17 -0
- package/src/i18n.ts +34 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +3 -2
- package/src/metadata.ts +10 -4
- package/src/migrate.ts +3 -0
- package/src/process-jobs.ts +7 -2
- package/src/restsync.ts +3 -2
- package/src/runtime/devbar.ts +3 -1
- package/src/runtime/display-selector.ts +12 -4
- package/src/runtime/grid.ts +170 -26
- package/src/runtime/lists.ts +11 -1
- package/src/runtime/password-reset.ts +171 -0
- package/src/runtime/pwa.ts +7 -10
- package/src/runtime/regions.ts +33 -0
- package/src/runtime/render.ts +15 -0
- package/src/runtime/report-views.ts +4 -2
- package/src/runtime/report.ts +36 -22
- package/src/runtime/routes.ts +39 -33
- package/src/runtime/signin.ts +80 -0
- package/src/sampledata.ts +3 -3
- package/src/session.ts +5 -2
- package/src/static-version.ts +34 -0
- package/src/websources.ts +3 -1
- package/src/workflow.ts +65 -9
- package/src/workspace-db.ts +166 -0
package/src/ddl.ts
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
|
|
3
|
+
// DDL of one schema, read from the catalog (APEX: SQL Workshop → Utilities →
|
|
4
|
+
// Generate DDL), and the comparison of two schemas (Schema Comparison).
|
|
5
|
+
//
|
|
6
|
+
// Every object becomes one entry: its kind, a key (kind and name, a
|
|
7
|
+
// function's name with its argument types, a constraint's or policy's table
|
|
8
|
+
// and name) and its DDL. readSchema() runs with search_path set to the
|
|
9
|
+
// schema: the server's pg_get_*def functions then leave the schema's own
|
|
10
|
+
// names unqualified, and so does this code, so the DDL of schema A also
|
|
11
|
+
// creates the same objects in schema B (with search_path = B), and two
|
|
12
|
+
// schemas compare by their DDL text. A script for one schema
|
|
13
|
+
// (ddlScript) puts the schema in search_path at its top.
|
|
14
|
+
//
|
|
15
|
+
// The order of a script: types and domains, sequences, procedural functions
|
|
16
|
+
// (check_function_bodies off), tables, SQL-standard functions (their bodies
|
|
17
|
+
// are parsed at once, so after the tables), views and materialized views in
|
|
18
|
+
// dependency order, constraints, foreign keys, indexes, triggers, row level
|
|
19
|
+
// security and policies, sequence ownership, comments, grants.
|
|
20
|
+
//
|
|
21
|
+
// Objects that belong to an extension are left out. Read-only: nothing is run.
|
|
22
|
+
|
|
23
|
+
export type Kind =
|
|
24
|
+
| 'type' | 'domain' | 'sequence' | 'table' | 'function' | 'view' | 'materialized view'
|
|
25
|
+
| 'constraint' | 'foreign key' | 'index' | 'trigger' | 'rls' | 'policy' | 'sequence owner' | 'comment' | 'grant';
|
|
26
|
+
|
|
27
|
+
export const KINDS: Kind[] = ['type', 'domain', 'sequence', 'table', 'function', 'view', 'materialized view', 'constraint', 'foreign key', 'index', 'trigger', 'rls', 'policy', 'sequence owner', 'comment', 'grant'];
|
|
28
|
+
|
|
29
|
+
/** The object types a developer picks (comments and grants are options of their own). */
|
|
30
|
+
export const OBJECT_TYPES = ['types', 'sequences', 'tables', 'views', 'functions', 'triggers', 'policies'] as const;
|
|
31
|
+
export type ObjectType = (typeof OBJECT_TYPES)[number];
|
|
32
|
+
|
|
33
|
+
const TYPE_OF: Record<Kind, ObjectType | 'comments' | 'grants'> = {
|
|
34
|
+
type: 'types', domain: 'types', sequence: 'sequences', 'sequence owner': 'sequences', table: 'tables', constraint: 'tables', 'foreign key': 'tables', index: 'tables',
|
|
35
|
+
function: 'functions', view: 'views', 'materialized view': 'views', trigger: 'triggers', rls: 'policies', policy: 'policies', comment: 'comments', grant: 'grants',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export interface DdlObject {
|
|
39
|
+
kind: Kind;
|
|
40
|
+
/** kind and name: unique in a schema, the same for the same object in another schema */
|
|
41
|
+
key: string;
|
|
42
|
+
/** the object's name as shown (a table, "table.constraint", "f(int)") */
|
|
43
|
+
name: string;
|
|
44
|
+
ddl: string;
|
|
45
|
+
/** the order within its kind (views: by dependency) */
|
|
46
|
+
order: number;
|
|
47
|
+
/** a table's columns, for column-level changes in a comparison */
|
|
48
|
+
columns?: Column[];
|
|
49
|
+
/** what a comparison looks at, when not the DDL itself (a table: its columns in name order) */
|
|
50
|
+
compareAs?: string;
|
|
51
|
+
/** the table a constraint, index, trigger, policy or comment belongs to */
|
|
52
|
+
table?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Column {
|
|
56
|
+
name: string;
|
|
57
|
+
type: string;
|
|
58
|
+
notNull: boolean;
|
|
59
|
+
default: string | null;
|
|
60
|
+
definition: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// names are quoted like quote_ident(): only when needed (keywords from pg_get_keywords(), read once)
|
|
64
|
+
let keywords: Set<string> | null = null;
|
|
65
|
+
const id = (name: string) => (/^[a-z_][a-z0-9_$]*$/.test(name) && !keywords?.has(name) ? name : pg.escapeIdentifier(name));
|
|
66
|
+
const PLURAL: Partial<Record<Kind, string>> = { index: 'indexes', rls: 'row level security', policy: 'policies', 'sequence owner': 'sequence ownership', 'foreign key': 'foreign keys' };
|
|
67
|
+
const heading = (k: Kind) => PLURAL[k] ?? `${k}s`;
|
|
68
|
+
const lit = (s: string) => `'${s.replace(/'/g, "''")}'`;
|
|
69
|
+
const end = (s: string) => (s.trimEnd().endsWith(';') ? s.trimEnd() : `${s.trimEnd()};`);
|
|
70
|
+
|
|
71
|
+
/** The schemas a developer can pick: not PostgreSQL's own, not pgkiln's metadata. */
|
|
72
|
+
export async function userSchemas(c: { query: (sql: string) => Promise<{ rows: { name: string }[] }> }, opts: { usable?: boolean } = {}) {
|
|
73
|
+
const r = await c.query(
|
|
74
|
+
`select nspname as name from pg_namespace
|
|
75
|
+
where nspname !~ '^pg_' and nspname not in ('information_schema', 'meta')
|
|
76
|
+
and not exists (select 1 from pg_depend d where d.classid = 'pg_namespace'::regclass and d.objid = pg_namespace.oid and d.deptype = 'e')
|
|
77
|
+
${opts.usable ? `and has_schema_privilege(oid, 'USAGE')` : ''}
|
|
78
|
+
order by 1`,
|
|
79
|
+
);
|
|
80
|
+
return r.rows.map((x) => x.name);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every object of the schema (in a read-only transaction of its own: search_path is set for it). */
|
|
84
|
+
export async function readSchema(db: pg.Pool | pg.ClientBase, schema: string): Promise<DdlObject[]> {
|
|
85
|
+
const pooled = db instanceof pg.Pool;
|
|
86
|
+
const c = pooled ? await db.connect() : db;
|
|
87
|
+
try {
|
|
88
|
+
await c.query('begin read only');
|
|
89
|
+
keywords ??= new Set((await c.query<{ w: string }>(`select word as w from pg_get_keywords() where catcode <> 'U'`)).rows.map((x) => x.w));
|
|
90
|
+
const ns = (await c.query<{ oid: number }>('select oid from pg_namespace where nspname = $1', [schema])).rows[0];
|
|
91
|
+
if (!ns) throw new Error(`There is no schema "${schema}".`);
|
|
92
|
+
await c.query(`select set_config('search_path', $1, true)`, [`${id(schema)}, pg_catalog`]);
|
|
93
|
+
const out = await read(c, ns.oid, schema);
|
|
94
|
+
await c.query('rollback');
|
|
95
|
+
return out;
|
|
96
|
+
} catch (e) {
|
|
97
|
+
await c.query('rollback').catch(() => {});
|
|
98
|
+
throw e;
|
|
99
|
+
} finally {
|
|
100
|
+
if (pooled) (c as pg.PoolClient).release();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// not an extension's member
|
|
105
|
+
const notExt = (cls: string, col = 'oid') =>
|
|
106
|
+
`not exists (select 1 from pg_depend d where d.classid = '${cls}'::regclass and d.objid = ${col} and d.deptype = 'e')`;
|
|
107
|
+
|
|
108
|
+
async function read(c: pg.ClientBase, ns: number, schema: string): Promise<DdlObject[]> {
|
|
109
|
+
// pg_get_functiondef and pg_get_indexdef name the schema whatever search_path says: leave it out
|
|
110
|
+
const own = `${pg.escapeIdentifier(schema) === `"${schema}"` && /^[a-z_][a-z0-9_$]*$/.test(schema) ? schema : pg.escapeIdentifier(schema)}.`;
|
|
111
|
+
const unqualify = (sql: string, before: RegExp) => sql.replace(before, (m) => m.replace(own, ''));
|
|
112
|
+
const out: DdlObject[] = [];
|
|
113
|
+
const add = (o: Omit<DdlObject, 'order'> & { order?: number }) => out.push({ order: 0, ...o });
|
|
114
|
+
const q = async <T extends pg.QueryResultRow>(sql: string, params: unknown[] = [ns]) => (await c.query<T>(sql, params)).rows;
|
|
115
|
+
|
|
116
|
+
// ---- types: enums and composite types; domains
|
|
117
|
+
for (const t of await q<{ name: string; labels: string[] | null; attrs: string | null }>(
|
|
118
|
+
`select t.typname as name,
|
|
119
|
+
case when t.typtype = 'e' then array(select e.enumlabel::text from pg_enum e where e.enumtypid = t.oid order by e.enumsortorder) end as labels,
|
|
120
|
+
case when t.typtype = 'c' then (select string_agg(quote_ident(a.attname) || ' ' || format_type(a.atttypid, a.atttypmod), ', ' order by a.attnum)
|
|
121
|
+
from pg_attribute a where a.attrelid = t.typrelid and a.attnum > 0 and not a.attisdropped) end as attrs
|
|
122
|
+
from pg_type t
|
|
123
|
+
where t.typnamespace = $1 and (t.typtype = 'e' or (t.typtype = 'c' and (select relkind from pg_class where oid = t.typrelid) = 'c'))
|
|
124
|
+
and ${notExt('pg_type', 't.oid')}
|
|
125
|
+
order by 1`,
|
|
126
|
+
))
|
|
127
|
+
add({ kind: 'type', key: `type:${t.name}`, name: t.name, ddl: t.labels ? `create type ${id(t.name)} as enum (${t.labels.map(lit).join(', ')});` : `create type ${id(t.name)} as (${t.attrs ?? ''});` });
|
|
128
|
+
|
|
129
|
+
for (const d of await q<{ name: string; base: string; collation: string | null; def: string | null; notnull: boolean; checks: string[] }>(
|
|
130
|
+
`select t.typname as name, format_type(t.typbasetype, t.typtypmod) as base,
|
|
131
|
+
case when t.typcollation <> 0 and t.typcollation <> (select typcollation from pg_type b where b.oid = t.typbasetype)
|
|
132
|
+
then (select quote_ident(collname) from pg_collation where oid = t.typcollation) end as collation,
|
|
133
|
+
t.typdefault as def, t.typnotnull as notnull,
|
|
134
|
+
array(select 'constraint ' || quote_ident(k.conname) || ' ' || pg_get_constraintdef(k.oid) from pg_constraint k where k.contypid = t.oid order by k.conname) as checks
|
|
135
|
+
from pg_type t where t.typnamespace = $1 and t.typtype = 'd' and ${notExt('pg_type', 't.oid')} order by 1`,
|
|
136
|
+
))
|
|
137
|
+
add({
|
|
138
|
+
kind: 'domain', key: `domain:${d.name}`, name: d.name,
|
|
139
|
+
ddl: `create domain ${id(d.name)} as ${d.base}${d.collation ? ` collate ${d.collation}` : ''}${d.def !== null ? ` default ${d.def}` : ''}${d.notnull ? ' not null' : ''}${d.checks.map((k) => `\n ${k}`).join('')};`,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ---- sequences: not those of identity columns (the column creates them)
|
|
143
|
+
for (const s of await q<{ name: string; type: string; inc: string; min: string; max: string; start: string; cache: string; cycle: boolean; owner: string | null }>(
|
|
144
|
+
`select c.relname as name, format_type(s.seqtypid, null) as type, s.seqincrement::text as inc, s.seqmin::text as min, s.seqmax::text as max,
|
|
145
|
+
s.seqstart::text as start, s.seqcache::text as cache, s.seqcycle as cycle,
|
|
146
|
+
(select quote_ident(t.relname) || '.' || quote_ident(a.attname)
|
|
147
|
+
from pg_depend d join pg_class t on t.oid = d.refobjid join pg_attribute a on a.attrelid = t.oid and a.attnum = d.refobjsubid
|
|
148
|
+
where d.classid = 'pg_class'::regclass and d.objid = c.oid and d.refclassid = 'pg_class'::regclass and d.deptype = 'a' and t.relnamespace = $1) as owner
|
|
149
|
+
from pg_class c join pg_sequence s on s.seqrelid = c.oid
|
|
150
|
+
where c.relnamespace = $1 and c.relkind = 'S' and ${notExt('pg_class', 'c.oid')}
|
|
151
|
+
and not exists (select 1 from pg_depend d where d.classid = 'pg_class'::regclass and d.objid = c.oid and d.deptype = 'i')
|
|
152
|
+
order by 1`,
|
|
153
|
+
)) {
|
|
154
|
+
add({ kind: 'sequence', key: `sequence:${s.name}`, name: s.name, ddl: `create sequence ${id(s.name)} as ${s.type} increment by ${s.inc} minvalue ${s.min} maxvalue ${s.max} start with ${s.start} cache ${s.cache}${s.cycle ? ' cycle' : ''};` });
|
|
155
|
+
if (s.owner) add({ kind: 'sequence owner', key: `sequence owner:${s.name}`, name: s.name, ddl: `alter sequence ${id(s.name)} owned by ${s.owner};` });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---- tables (partitioned ones before their partitions)
|
|
159
|
+
const tables = await q<{ oid: number; name: string; kind: string; unlogged: boolean; partkey: string | null; parent: string | null; bound: string | null; rls: boolean; force: boolean; options: string[] | null }>(
|
|
160
|
+
`select c.oid, c.relname as name, c.relkind as kind, c.relpersistence = 'u' as unlogged,
|
|
161
|
+
case when c.relkind = 'p' then pg_get_partkeydef(c.oid) end as partkey,
|
|
162
|
+
(select p.relname from pg_inherits i join pg_class p on p.oid = i.inhparent where i.inhrelid = c.oid and c.relispartition) as parent,
|
|
163
|
+
case when c.relispartition then pg_get_expr(c.relpartbound, c.oid) end as bound,
|
|
164
|
+
c.relrowsecurity as rls, c.relforcerowsecurity as force, c.reloptions as options
|
|
165
|
+
from pg_class c where c.relnamespace = $1 and c.relkind in ('r', 'p') and ${notExt('pg_class', 'c.oid')}
|
|
166
|
+
order by c.relispartition, c.relname`,
|
|
167
|
+
);
|
|
168
|
+
for (const t of tables) {
|
|
169
|
+
const cols = await q<{ name: string; type: string; notnull: boolean; def: string | null; identity: string; generated: string; collation: string | null; inherited: boolean }>(
|
|
170
|
+
`select a.attname as name, format_type(a.atttypid, a.atttypmod) as type, a.attnotnull as notnull,
|
|
171
|
+
pg_get_expr(d.adbin, d.adrelid) as def, a.attidentity as identity, a.attgenerated as generated, a.attislocal = false as inherited,
|
|
172
|
+
case when a.attcollation <> 0 and a.attcollation <> (select typcollation from pg_type where oid = a.atttypid)
|
|
173
|
+
then (select quote_ident(collname) from pg_collation where oid = a.attcollation) end as collation
|
|
174
|
+
from pg_attribute a left join pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum
|
|
175
|
+
where a.attrelid = $1 and a.attnum > 0 and not a.attisdropped order by a.attnum`,
|
|
176
|
+
[t.oid],
|
|
177
|
+
);
|
|
178
|
+
const columns: Column[] = cols.map((x) => {
|
|
179
|
+
const def = x.generated === 's' ? null : x.def;
|
|
180
|
+
const parts = [
|
|
181
|
+
`${id(x.name)} ${x.type}`,
|
|
182
|
+
x.collation ? `collate ${x.collation}` : '',
|
|
183
|
+
x.generated === 's' ? `generated always as (${x.def}) stored` : '',
|
|
184
|
+
x.identity === 'a' ? 'generated always as identity' : x.identity === 'd' ? 'generated by default as identity' : '',
|
|
185
|
+
def !== null && !x.identity ? `default ${def}` : '',
|
|
186
|
+
x.notnull ? 'not null' : '',
|
|
187
|
+
].filter(Boolean);
|
|
188
|
+
return { name: x.name, type: x.type, notNull: x.notnull, default: x.identity ? `identity ${x.identity}` : x.generated === 's' ? `generated ${x.def}` : def, definition: parts.join(' ') };
|
|
189
|
+
});
|
|
190
|
+
const opts = t.options?.length ? ` with (${t.options.join(', ')})` : '';
|
|
191
|
+
const ddl = t.parent
|
|
192
|
+
? `create${t.unlogged ? ' unlogged' : ''} table ${id(t.name)} partition of ${id(t.parent)} ${t.bound}${t.partkey ? ` partition by ${t.partkey}` : ''}${opts};`
|
|
193
|
+
: `create${t.unlogged ? ' unlogged' : ''} table ${id(t.name)} (\n${columns.map((x) => ` ${x.definition}`).join(',\n')}\n)${t.partkey ? ` partition by ${t.partkey}` : ''}${opts};`;
|
|
194
|
+
// a comparison ignores the order of the columns
|
|
195
|
+
const compareAs = t.parent ? undefined : ddl.replace(/\(\n[\s\S]*\n\)/, `(\n${columns.map((x) => ` ${x.definition}`).sort().join(',\n')}\n)`);
|
|
196
|
+
add({ kind: 'table', key: `table:${t.name}`, name: t.name, ddl, columns: t.parent ? undefined : columns, compareAs });
|
|
197
|
+
if (t.rls || t.force)
|
|
198
|
+
add({ kind: 'rls', key: `rls:${t.name}`, name: t.name, table: t.name,
|
|
199
|
+
ddl: [t.rls ? `alter table ${id(t.name)} enable row level security;` : '', t.force ? `alter table ${id(t.name)} force row level security;` : ''].filter(Boolean).join('\n') });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---- constraints (a partition's inherited ones come with it)
|
|
203
|
+
for (const k of await q<{ table: string; name: string; type: string; def: string }>(
|
|
204
|
+
`select t.relname as table, k.conname as name, k.contype as type, pg_get_constraintdef(k.oid) as def
|
|
205
|
+
from pg_constraint k join pg_class t on t.oid = k.conrelid
|
|
206
|
+
where t.relnamespace = $1 and t.relkind in ('r', 'p') and k.contype in ('p', 'u', 'c', 'x', 'f') and k.conislocal and ${notExt('pg_class', 't.oid')}
|
|
207
|
+
and (k.conparentid = 0 or k.conparentid is null)
|
|
208
|
+
order by t.relname, k.contype, k.conname`,
|
|
209
|
+
))
|
|
210
|
+
add({
|
|
211
|
+
kind: k.type === 'f' ? 'foreign key' : 'constraint', key: `${k.type === 'f' ? 'foreign key' : 'constraint'}:${k.table}.${k.name}`, name: `${k.table}.${k.name}`, table: k.table,
|
|
212
|
+
ddl: `alter table ${id(k.table)} add constraint ${id(k.name)} ${k.def};`,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ---- indexes that no constraint makes, not on partitions (the partitioned table's index makes them)
|
|
216
|
+
for (const i of await q<{ table: string; name: string; def: string }>(
|
|
217
|
+
`select t.relname as table, ic.relname as name, pg_get_indexdef(ic.oid) as def
|
|
218
|
+
from pg_index x join pg_class ic on ic.oid = x.indexrelid join pg_class t on t.oid = x.indrelid
|
|
219
|
+
where t.relnamespace = $1 and t.relkind in ('r', 'p', 'm') and not exists (select 1 from pg_constraint k where k.conindid = ic.oid and k.contype in ('p', 'u', 'x'))
|
|
220
|
+
and not t.relispartition and ${notExt('pg_class', 't.oid')}
|
|
221
|
+
order by t.relname, ic.relname`,
|
|
222
|
+
))
|
|
223
|
+
add({ kind: 'index', key: `index:${i.name}`, name: i.name, table: i.table, ddl: `${unqualify(i.def, / ON (ONLY )?\S+ USING /)};` });
|
|
224
|
+
|
|
225
|
+
// ---- functions and procedures (SQL-standard bodies after the tables)
|
|
226
|
+
for (const f of await q<{ sig: string; def: string; atomic: boolean }>(
|
|
227
|
+
`select p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')' as sig, pg_get_functiondef(p.oid) as def, p.prosqlbody is not null as atomic
|
|
228
|
+
from pg_proc p where p.pronamespace = $1 and p.prokind in ('f', 'p', 'w') and ${notExt('pg_proc', 'p.oid')}
|
|
229
|
+
order by 1`,
|
|
230
|
+
))
|
|
231
|
+
add({ kind: 'function', key: `function:${f.sig}`, name: f.sig, ddl: end(unqualify(f.def, /^CREATE OR REPLACE (FUNCTION|PROCEDURE) \S+?\(/)), order: f.atomic ? 1 : 0 });
|
|
232
|
+
|
|
233
|
+
// ---- views and materialized views, in dependency order
|
|
234
|
+
const views = await q<{ oid: number; name: string; kind: string; def: string; options: string[] | null; deps: number[] }>(
|
|
235
|
+
`select c.oid, c.relname as name, c.relkind as kind, pg_get_viewdef(c.oid, true) as def, c.reloptions as options,
|
|
236
|
+
array(select distinct d.refobjid::int from pg_rewrite r join pg_depend d on d.classid = 'pg_rewrite'::regclass and d.objid = r.oid
|
|
237
|
+
where r.ev_class = c.oid and d.refobjid <> c.oid and d.refclassid = 'pg_class'::regclass) as deps
|
|
238
|
+
from pg_class c where c.relnamespace = $1 and c.relkind in ('v', 'm') and ${notExt('pg_class', 'c.oid')}
|
|
239
|
+
order by c.relname`,
|
|
240
|
+
);
|
|
241
|
+
const position = new Map<number, number>();
|
|
242
|
+
const visit = (v: (typeof views)[number], seen: Set<number>): number => {
|
|
243
|
+
if (position.has(v.oid)) return position.get(v.oid)!;
|
|
244
|
+
if (seen.has(v.oid)) return 0;
|
|
245
|
+
seen.add(v.oid);
|
|
246
|
+
const after = Math.max(0, ...v.deps.map((d) => views.find((x) => x.oid === d)).filter((x): x is (typeof views)[number] => !!x).map((x) => visit(x, seen) + 1));
|
|
247
|
+
position.set(v.oid, after);
|
|
248
|
+
return after;
|
|
249
|
+
};
|
|
250
|
+
for (const v of views) {
|
|
251
|
+
const opts = v.options?.length ? ` with (${v.options.join(', ')})` : '';
|
|
252
|
+
const body = v.def.trim().replace(/;$/, '');
|
|
253
|
+
add({
|
|
254
|
+
kind: v.kind === 'm' ? 'materialized view' : 'view', key: `${v.kind === 'm' ? 'materialized view' : 'view'}:${v.name}`, name: v.name, order: visit(v, new Set()),
|
|
255
|
+
ddl: v.kind === 'm' ? `create materialized view ${id(v.name)}${opts} as\n${body}\nwith no data;` : `create or replace view ${id(v.name)}${opts} as\n${body};`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---- triggers
|
|
260
|
+
for (const t of await q<{ table: string; name: string; def: string }>(
|
|
261
|
+
`select c.relname as table, g.tgname as name, pg_get_triggerdef(g.oid, true) as def
|
|
262
|
+
from pg_trigger g join pg_class c on c.oid = g.tgrelid
|
|
263
|
+
where c.relnamespace = $1 and not g.tgisinternal and g.tgparentid = 0 and ${notExt('pg_class', 'c.oid')}
|
|
264
|
+
order by 1, 2`,
|
|
265
|
+
))
|
|
266
|
+
add({ kind: 'trigger', key: `trigger:${t.table}.${t.name}`, name: `${t.table}.${t.name}`, table: t.table, ddl: `${t.def};` });
|
|
267
|
+
|
|
268
|
+
// ---- row level security policies
|
|
269
|
+
for (const p of await q<{ table: string; name: string; permissive: boolean; cmd: string; roles: string[]; using: string | null; check: string | null }>(
|
|
270
|
+
`select c.relname as table, p.polname as name, p.polpermissive as permissive, p.polcmd::text as cmd,
|
|
271
|
+
array(select case when r = 0 then 'public' else quote_ident(pg_get_userbyid(r)) end from unnest(p.polroles) r order by 1) as roles,
|
|
272
|
+
pg_get_expr(p.polqual, p.polrelid) as using, pg_get_expr(p.polwithcheck, p.polrelid) as check
|
|
273
|
+
from pg_policy p join pg_class c on c.oid = p.polrelid where c.relnamespace = $1 order by 1, 2`,
|
|
274
|
+
)) {
|
|
275
|
+
const cmd = { r: 'select', a: 'insert', w: 'update', d: 'delete', '*': 'all' }[p.cmd] ?? 'all';
|
|
276
|
+
add({
|
|
277
|
+
kind: 'policy', key: `policy:${p.table}.${p.name}`, name: `${p.table}.${p.name}`, table: p.table,
|
|
278
|
+
ddl: `create policy ${id(p.name)} on ${id(p.table)} as ${p.permissive ? 'permissive' : 'restrictive'} for ${cmd} to ${p.roles.join(', ')}${p.using ? `\n using (${p.using})` : ''}${p.check ? `\n with check (${p.check})` : ''};`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---- comments
|
|
283
|
+
for (const m of await q<{ what: string; target: string; text: string }>(
|
|
284
|
+
`select case c.relkind when 'v' then 'view' when 'm' then 'materialized view' when 'S' then 'sequence' else 'table' end as what,
|
|
285
|
+
quote_ident(c.relname) as target, d.description as text
|
|
286
|
+
from pg_description d join pg_class c on c.oid = d.objoid and d.classoid = 'pg_class'::regclass and d.objsubid = 0
|
|
287
|
+
where c.relnamespace = $1 and c.relkind in ('r', 'p', 'v', 'm', 'S') and ${notExt('pg_class', 'c.oid')}
|
|
288
|
+
union all
|
|
289
|
+
select 'column', quote_ident(c.relname) || '.' || quote_ident(a.attname), d.description
|
|
290
|
+
from pg_description d join pg_class c on c.oid = d.objoid and d.classoid = 'pg_class'::regclass
|
|
291
|
+
join pg_attribute a on a.attrelid = c.oid and a.attnum = d.objsubid
|
|
292
|
+
where c.relnamespace = $1 and d.objsubid > 0 and ${notExt('pg_class', 'c.oid')}
|
|
293
|
+
union all
|
|
294
|
+
select case p.prokind when 'p' then 'procedure' else 'function' end, quote_ident(p.proname) || '(' || pg_get_function_identity_arguments(p.oid) || ')', d.description
|
|
295
|
+
from pg_description d join pg_proc p on p.oid = d.objoid and d.classoid = 'pg_proc'::regclass
|
|
296
|
+
where p.pronamespace = $1 and ${notExt('pg_proc', 'p.oid')}
|
|
297
|
+
union all
|
|
298
|
+
select case t.typtype when 'd' then 'domain' else 'type' end, quote_ident(t.typname), d.description
|
|
299
|
+
from pg_description d join pg_type t on t.oid = d.objoid and d.classoid = 'pg_type'::regclass
|
|
300
|
+
where t.typnamespace = $1 and ${notExt('pg_type', 't.oid')}
|
|
301
|
+
order by 1, 2`,
|
|
302
|
+
))
|
|
303
|
+
add({ kind: 'comment', key: `comment:${m.what} ${m.target}`, name: `${m.what} ${m.target}`, ddl: `comment on ${m.what} ${m.target} is ${lit(m.text)};` });
|
|
304
|
+
|
|
305
|
+
// ---- functions that PUBLIC may not run (by default it may): the revoke comes first
|
|
306
|
+
for (const f of await q<{ what: string; sig: string }>(
|
|
307
|
+
`select case p.prokind when 'p' then 'procedure' else 'function' end as what,
|
|
308
|
+
quote_ident(p.proname) || '(' || pg_get_function_identity_arguments(p.oid) || ')' as sig
|
|
309
|
+
from pg_proc p
|
|
310
|
+
where p.pronamespace = $1 and p.proacl is not null and ${notExt('pg_proc', 'p.oid')}
|
|
311
|
+
and not exists (select 1 from aclexplode(p.proacl) a where a.grantee = 0 and a.privilege_type = 'EXECUTE')
|
|
312
|
+
order by 2`,
|
|
313
|
+
))
|
|
314
|
+
add({ kind: 'grant', key: `grant:${f.what} ${f.sig} public`, name: `${f.what} ${f.sig} not to public`, ddl: `revoke execute on ${f.what} ${f.sig} from public;` });
|
|
315
|
+
|
|
316
|
+
// ---- grants (owners' own privileges are not repeated)
|
|
317
|
+
for (const g of await q<{ what: string; target: string; grantee: string; privs: string[]; grantable: boolean }>(
|
|
318
|
+
`select x.what, x.target, case when a.grantee = 0 then 'public' else quote_ident(pg_get_userbyid(a.grantee)) end as grantee,
|
|
319
|
+
array_agg(a.privilege_type order by a.privilege_type) as privs, bool_and(a.is_grantable) as grantable
|
|
320
|
+
from (select case when c.relkind = 'S' then 'sequence' else 'table' end as what, quote_ident(c.relname) as target, c.relacl as acl, c.relowner as owner
|
|
321
|
+
from pg_class c where c.relnamespace = $1 and c.relkind in ('r', 'p', 'v', 'm', 'S') and c.relacl is not null and ${notExt('pg_class', 'c.oid')}
|
|
322
|
+
union all
|
|
323
|
+
select case p.prokind when 'p' then 'procedure' else 'function' end, quote_ident(p.proname) || '(' || pg_get_function_identity_arguments(p.oid) || ')', p.proacl, p.proowner
|
|
324
|
+
from pg_proc p where p.pronamespace = $1 and p.proacl is not null and ${notExt('pg_proc', 'p.oid')}) x,
|
|
325
|
+
aclexplode(x.acl) a
|
|
326
|
+
where a.grantee <> x.owner
|
|
327
|
+
group by x.what, x.target, a.grantee
|
|
328
|
+
order by 1, 2, 3`,
|
|
329
|
+
))
|
|
330
|
+
add({ kind: 'grant', key: `grant:${g.what} ${g.target} ${g.grantee}`, name: `${g.what} ${g.target} to ${g.grantee}`, ddl: `grant ${g.privs.join(', ').toLowerCase()} on ${g.what === 'table' ? 'table ' : g.what === 'sequence' ? 'sequence ' : `${g.what} `}${g.target} to ${g.grantee}${g.grantable ? ' with grant option' : ''};` });
|
|
331
|
+
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Order objects for a script. */
|
|
336
|
+
export function scriptOrder(objects: DdlObject[]) {
|
|
337
|
+
const rank = (o: DdlObject) => {
|
|
338
|
+
if (o.kind === 'function') return o.order === 0 ? KINDS.indexOf('sequence') + 0.5 : KINDS.indexOf('table') + 0.5;
|
|
339
|
+
return KINDS.indexOf(o.kind);
|
|
340
|
+
};
|
|
341
|
+
return [...objects].sort((a, b) => rank(a) - rank(b) || a.order - b.order || a.key.localeCompare(b.key));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Keep the chosen object types (and comments, grants when asked). */
|
|
345
|
+
export function pick(objects: DdlObject[], types: Set<string>, o: { comments: boolean; grants: boolean }) {
|
|
346
|
+
return objects.filter((x) => {
|
|
347
|
+
const t = TYPE_OF[x.kind];
|
|
348
|
+
if (t === 'comments') return o.comments;
|
|
349
|
+
if (t === 'grants') return o.grants;
|
|
350
|
+
return types.has(t);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const HEADER = (title: string, schema: string) => `-- ${title}
|
|
355
|
+
-- Generated by pgkiln on ${new Date().toISOString().slice(0, 19).replace('T', ' ')} UTC. Review before running.
|
|
356
|
+
set check_function_bodies = off;
|
|
357
|
+
create schema if not exists ${id(schema)};
|
|
358
|
+
set search_path = ${id(schema)}, pg_catalog;
|
|
359
|
+
`;
|
|
360
|
+
|
|
361
|
+
/** A script that creates the objects in the schema. */
|
|
362
|
+
export function ddlScript(schema: string, objects: DdlObject[]) {
|
|
363
|
+
let last = '';
|
|
364
|
+
const parts = scriptOrder(objects).map((o) => {
|
|
365
|
+
const head = o.kind !== last ? `\n-- ${heading(o.kind)}\n` : '';
|
|
366
|
+
last = o.kind;
|
|
367
|
+
return `${head}${o.ddl}`;
|
|
368
|
+
});
|
|
369
|
+
return `${HEADER(`DDL of schema ${schema}`, schema)}${parts.join('\n')}\n`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ---------------------------------------------------------------- comparison
|
|
373
|
+
|
|
374
|
+
export interface Difference {
|
|
375
|
+
key: string;
|
|
376
|
+
kind: Kind;
|
|
377
|
+
name: string;
|
|
378
|
+
/** in the source only, the target only, or in both but different */
|
|
379
|
+
state: 'source' | 'target' | 'different';
|
|
380
|
+
source?: string;
|
|
381
|
+
target?: string;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function compare(source: DdlObject[], target: DdlObject[]): { differences: Difference[]; same: number } {
|
|
385
|
+
const t = new Map(target.map((x) => [x.key, x]));
|
|
386
|
+
const s = new Map(source.map((x) => [x.key, x]));
|
|
387
|
+
const differences: Difference[] = [];
|
|
388
|
+
let same = 0;
|
|
389
|
+
for (const a of source) {
|
|
390
|
+
const b = t.get(a.key);
|
|
391
|
+
if (!b) differences.push({ key: a.key, kind: a.kind, name: a.name, state: 'source', source: a.ddl });
|
|
392
|
+
else if ((a.compareAs ?? a.ddl) !== (b.compareAs ?? b.ddl)) differences.push({ key: a.key, kind: a.kind, name: a.name, state: 'different', source: a.ddl, target: b.ddl });
|
|
393
|
+
else same++;
|
|
394
|
+
}
|
|
395
|
+
for (const b of target) if (!s.has(b.key)) differences.push({ key: b.key, kind: b.kind, name: b.name, state: 'target', target: b.ddl });
|
|
396
|
+
const rank = (d: Difference) => KINDS.indexOf(d.kind);
|
|
397
|
+
differences.sort((a, b) => rank(a) - rank(b) || a.key.localeCompare(b.key));
|
|
398
|
+
return { differences, same };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Drop statements of an object, for a change script. */
|
|
402
|
+
function dropOf(o: DdlObject): string | null {
|
|
403
|
+
switch (o.kind) {
|
|
404
|
+
case 'type': return `drop type ${id(o.name)};`;
|
|
405
|
+
case 'domain': return `drop domain ${id(o.name)};`;
|
|
406
|
+
case 'sequence': return `drop sequence ${id(o.name)};`;
|
|
407
|
+
case 'table': return `drop table ${id(o.name)};`;
|
|
408
|
+
case 'view': return `drop view ${id(o.name)};`;
|
|
409
|
+
case 'materialized view': return `drop materialized view ${id(o.name)};`;
|
|
410
|
+
case 'function': return `drop routine ${o.name.replace(/^([^(]+)/, (m) => id(m))};`;
|
|
411
|
+
case 'constraint':
|
|
412
|
+
case 'foreign key': return `alter table ${id(o.table!)} drop constraint ${id(o.name.slice(o.table!.length + 1))};`;
|
|
413
|
+
case 'index': return `drop index ${id(o.name)};`;
|
|
414
|
+
case 'trigger': return `drop trigger ${id(o.name.slice(o.table!.length + 1))} on ${id(o.table!)};`;
|
|
415
|
+
case 'policy': return `drop policy ${id(o.name.slice(o.table!.length + 1))} on ${id(o.table!)};`;
|
|
416
|
+
case 'rls': return `alter table ${id(o.table!)} disable row level security;\nalter table ${id(o.table!)} no force row level security;`;
|
|
417
|
+
case 'sequence owner': return `alter sequence ${id(o.name)} owned by none;`;
|
|
418
|
+
case 'comment': return `comment on ${o.name} is null;`;
|
|
419
|
+
case 'grant': {
|
|
420
|
+
const r = /^revoke (.+) on (.+) from public;$/s.exec(o.ddl);
|
|
421
|
+
if (r) return `grant ${r[1]} on ${r[2]} to public;`;
|
|
422
|
+
const m = /^grant (.+) on (.+) to (\S+?)( with grant option)?;$/s.exec(o.ddl);
|
|
423
|
+
return m ? `revoke ${m[1]} on ${m[2]} from ${m[3]};` : null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** A table's changes, column by column (the rest of a table: its constraints, indexes … are objects of their own). */
|
|
430
|
+
function alterTable(a: DdlObject, b: DdlObject): { sql: string[]; review: string[] } {
|
|
431
|
+
const sql: string[] = [];
|
|
432
|
+
const review: string[] = [];
|
|
433
|
+
const t = id(a.name);
|
|
434
|
+
if (!a.columns || !b.columns) return { sql, review: [`-- table ${a.name} differs (partitioning or options): compare by hand.`] };
|
|
435
|
+
const bc = new Map(b.columns.map((x) => [x.name, x]));
|
|
436
|
+
const ac = new Map(a.columns.map((x) => [x.name, x]));
|
|
437
|
+
for (const x of a.columns) {
|
|
438
|
+
const y = bc.get(x.name);
|
|
439
|
+
if (!y) {
|
|
440
|
+
sql.push(`alter table ${t} add column ${x.definition};`);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (x.definition === y.definition) continue;
|
|
444
|
+
if (x.default?.startsWith('identity') || y.default?.startsWith('identity') || x.default?.startsWith('generated') || y.default?.startsWith('generated')) {
|
|
445
|
+
review.push(`-- column ${a.name}.${x.name}: ${y.definition} → ${x.definition} (identity or generated: change by hand)`);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (x.type !== y.type) sql.push(`alter table ${t} alter column ${id(x.name)} type ${x.type};`);
|
|
449
|
+
if (x.default !== y.default) sql.push(x.default === null ? `alter table ${t} alter column ${id(x.name)} drop default;` : `alter table ${t} alter column ${id(x.name)} set default ${x.default};`);
|
|
450
|
+
if (x.notNull !== y.notNull) sql.push(`alter table ${t} alter column ${id(x.name)} ${x.notNull ? 'set' : 'drop'} not null;`);
|
|
451
|
+
if (x.definition.includes(' collate ') || y.definition.includes(' collate ')) review.push(`-- column ${a.name}.${x.name}: the collation differs`);
|
|
452
|
+
}
|
|
453
|
+
for (const y of b.columns) if (!ac.has(y.name)) review.push(`-- alter table ${t} drop column ${id(y.name)};`);
|
|
454
|
+
if (a.columns.map((x) => x.name).filter((n) => bc.has(n)).join() !== b.columns.map((x) => x.name).filter((n) => ac.has(n)).join())
|
|
455
|
+
review.push(`-- table ${a.name}: the columns are in another order (not changed)`);
|
|
456
|
+
return { sql, review };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* A script that makes the target schema like the source: what the source has and the target hasn't
|
|
461
|
+
* is created, what differs is replaced (functions and views in place, constraints, indexes, triggers,
|
|
462
|
+
* policies, grants and materialized views dropped and created again, tables column by column), and
|
|
463
|
+
* what only the target has is dropped, commented out: review it. Types, domains and sequences that
|
|
464
|
+
* differ are left for review (tables may use them).
|
|
465
|
+
*/
|
|
466
|
+
export function changeScript(sourceName: string, targetName: string, source: DdlObject[], target: DdlObject[]) {
|
|
467
|
+
const { differences } = compare(source, target);
|
|
468
|
+
const s = new Map(source.map((x) => [x.key, x]));
|
|
469
|
+
const t = new Map(target.map((x) => [x.key, x]));
|
|
470
|
+
const create: DdlObject[] = [];
|
|
471
|
+
const first: string[] = [];
|
|
472
|
+
const alters: string[] = [];
|
|
473
|
+
const reviews: string[] = [];
|
|
474
|
+
const gone: string[] = [];
|
|
475
|
+
const comment = (sql: string) => sql.split('\n').map((l) => `-- ${l}`).join('\n');
|
|
476
|
+
for (const d of differences) {
|
|
477
|
+
const a = s.get(d.key);
|
|
478
|
+
const b = t.get(d.key);
|
|
479
|
+
if (d.state === 'source') create.push(a!);
|
|
480
|
+
else if (d.state === 'target') {
|
|
481
|
+
const sql = dropOf(b!);
|
|
482
|
+
if (sql) gone.push(comment(sql));
|
|
483
|
+
} else if (a!.kind === 'table') {
|
|
484
|
+
const r = alterTable(a!, b!);
|
|
485
|
+
alters.push(...r.sql);
|
|
486
|
+
reviews.push(...r.review);
|
|
487
|
+
} else if (a!.kind === 'function' || a!.kind === 'view' || a!.kind === 'comment' || a!.kind === 'sequence owner') {
|
|
488
|
+
create.push(a!);
|
|
489
|
+
} else if (a!.kind === 'type' || a!.kind === 'domain' || a!.kind === 'sequence') {
|
|
490
|
+
reviews.push(`-- ${a!.kind} ${a!.name} differs; in ${targetName}:`, comment(b!.ddl), `-- in ${sourceName}:`, comment(a!.ddl));
|
|
491
|
+
} else {
|
|
492
|
+
const sql = dropOf(b!);
|
|
493
|
+
if (sql) first.push(sql);
|
|
494
|
+
create.push(a!);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const lines = [HEADER(`Make schema ${targetName} like ${sourceName}`, targetName).replace(/create schema if not exists [^\n]+\n/, '')];
|
|
498
|
+
if (!differences.length) lines.push('-- the schemas are the same: nothing to change');
|
|
499
|
+
if (first.length) lines.push('-- replaced objects: the old ones first', ...first);
|
|
500
|
+
if (alters.length) lines.push('', '-- changed tables', ...alters);
|
|
501
|
+
let last = '';
|
|
502
|
+
for (const o of scriptOrder(create)) {
|
|
503
|
+
if (o.kind !== last) lines.push('', `-- ${heading(o.kind)}`);
|
|
504
|
+
last = o.kind;
|
|
505
|
+
lines.push(o.ddl);
|
|
506
|
+
}
|
|
507
|
+
if (reviews.length) lines.push('', '-- review: not changed by this script', ...reviews);
|
|
508
|
+
if (gone.length) lines.push('', `-- only in ${targetName}: dropped when you remove the comment marks`, ...gone);
|
|
509
|
+
return `${lines.join('\n')}\n`;
|
|
510
|
+
}
|
package/src/debug.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// Item values are only written at level 9, and never those of password items.
|
|
10
10
|
import type { FastifyRequest } from 'fastify';
|
|
11
11
|
import { runtime } from './db.ts';
|
|
12
|
+
import { withAppDb } from './workspace-db.ts';
|
|
12
13
|
|
|
13
14
|
export const DEBUG_LEVELS: [number, string][] = [
|
|
14
15
|
[0, 'Off'],
|
|
@@ -92,10 +93,10 @@ export class DebugLog {
|
|
|
92
93
|
if (this.saved) return;
|
|
93
94
|
this.saved = true;
|
|
94
95
|
if (this.dropped) this.entries.push({ ms: round(performance.now() - this.started), level: 1, component: 'debug', text: `${this.dropped} more entries left out (at most ${MAX_ENTRIES} per request)` });
|
|
95
|
-
await runtime.query('select meta.debug_save($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb)', [
|
|
96
|
+
await withAppDb(this.appId, () => runtime.query('select meta.debug_save($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb)', [
|
|
96
97
|
this.appId, this.pageNo, this.username, this.sessionId, this.method, this.path, status, this.level,
|
|
97
98
|
this.startedAt.toISOString(), round(performance.now() - this.started), JSON.stringify(this.entries),
|
|
98
|
-
]);
|
|
99
|
+
]));
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
package/src/i18n/ar.ts
CHANGED
|
@@ -448,6 +448,23 @@ export const ar: Record<MessageKey, string> = {
|
|
|
448
448
|
'grid.column_position': "موضع {column}",
|
|
449
449
|
'grid.column_width': "عرض {column} بالبكسل",
|
|
450
450
|
'grid.default_aggregates': "تُعرض دائمًا: {list}.",
|
|
451
|
+
'reset.link': "هل نسيت كلمة المرور؟",
|
|
452
|
+
'reset.title': "إعادة تعيين كلمة المرور",
|
|
453
|
+
'reset.intro': "أدخل اسم المستخدم أو عنوان بريدك الإلكتروني. إذا كان يخص حسابًا، فسنرسل إليك رابطًا لاختيار كلمة مرور جديدة.",
|
|
454
|
+
'reset.login_label': "اسم المستخدم أو البريد الإلكتروني",
|
|
455
|
+
'reset.send': "إرسال الرابط",
|
|
456
|
+
'reset.sent': "إذا كان يطابق حسابًا، فهناك رسالة بريد إلكتروني تحتوي على رابط في الطريق. يعمل الرابط مرة واحدة لمدة 30 دقيقة.",
|
|
457
|
+
'reset.mail_subject': "اختر كلمة مرور جديدة لـ {app}",
|
|
458
|
+
'reset.mail_body': "مرحبًا #USER#،\n\nطلب أحدهم، ونأمل أن تكون أنت، اختيار كلمة مرور جديدة لـ {app}. افتح هذا الرابط خلال 30 دقيقة لفعل ذلك:\n\n{link}\n\nإذا لم تطلب ذلك، فتجاهل هذه الرسالة: ستبقى كلمة مرورك كما هي.",
|
|
459
|
+
'reset.new_title': "اختر كلمة مرور جديدة",
|
|
460
|
+
'reset.invalid': "هذا الرابط غير صالح (أو لم يعد صالحًا). اطلب رابطًا جديدًا.",
|
|
461
|
+
'reset.done': "تم تغيير كلمة المرور. سجّل الدخول بالكلمة الجديدة.",
|
|
462
|
+
'reset.back': "العودة إلى تسجيل الدخول",
|
|
463
|
+
'grid.view_grid': "شبكة",
|
|
464
|
+
'grid.view_row': "عرض صف واحد",
|
|
465
|
+
'grid.row_of': "الصف {row} من {total}",
|
|
466
|
+
'grid.first': "الأول",
|
|
467
|
+
'grid.last': "الأخير",
|
|
451
468
|
'chart.other': "أخرى",
|
|
452
469
|
'chart.label': "التسمية",
|
|
453
470
|
'chart.bubble': "فقاعي",
|
package/src/i18n/cs.ts
CHANGED
|
@@ -448,6 +448,23 @@ export const cs: Record<MessageKey, string> = {
|
|
|
448
448
|
'grid.column_position': "Pozice sloupce {column}",
|
|
449
449
|
'grid.column_width': "Šířka sloupce {column} v pixelech",
|
|
450
450
|
'grid.default_aggregates': "Vždy zobrazeno: {list}.",
|
|
451
|
+
'reset.link': "Zapomenuté heslo?",
|
|
452
|
+
'reset.title': "Obnovení hesla",
|
|
453
|
+
'reset.intro': "Zadejte své uživatelské jméno nebo e-mailovou adresu. Pokud patří k účtu, pošleme vám odkaz pro zvolení nového hesla.",
|
|
454
|
+
'reset.login_label': "Uživatelské jméno nebo e-mail",
|
|
455
|
+
'reset.send': "Odeslat odkaz",
|
|
456
|
+
'reset.sent': "Pokud odpovídá účtu, je na cestě e-mail s odkazem. Odkaz funguje jednou, po dobu 30 minut.",
|
|
457
|
+
'reset.mail_subject': "Zvolte nové heslo pro {app}",
|
|
458
|
+
'reset.mail_body': "Dobrý den, #USER#,\n\nněkdo, snad vy, požádal o nové heslo pro {app}. Otevřete do 30 minut tento odkaz a zvolte ho:\n\n{link}\n\nPokud jste o nic nežádali, tento e-mail ignorujte: vaše heslo zůstává beze změny.",
|
|
459
|
+
'reset.new_title': "Zvolte nové heslo",
|
|
460
|
+
'reset.invalid': "Tento odkaz není (už) platný. Požádejte o nový.",
|
|
461
|
+
'reset.done': "Vaše heslo bylo změněno. Přihlaste se novým heslem.",
|
|
462
|
+
'reset.back': "Zpět na přihlášení",
|
|
463
|
+
'grid.view_grid': "Mřížka",
|
|
464
|
+
'grid.view_row': "Zobrazení jednoho řádku",
|
|
465
|
+
'grid.row_of': "Řádek {row} z {total}",
|
|
466
|
+
'grid.first': "První",
|
|
467
|
+
'grid.last': "Poslední",
|
|
451
468
|
'chart.other': "Ostatní",
|
|
452
469
|
'chart.label': "Popisek",
|
|
453
470
|
'chart.bubble': "Bublinový",
|
package/src/i18n/da.ts
CHANGED
|
@@ -448,6 +448,23 @@ export const da: Record<MessageKey, string> = {
|
|
|
448
448
|
'grid.column_position': "Placering af {column}",
|
|
449
449
|
'grid.column_width': "Bredde af {column} i pixels",
|
|
450
450
|
'grid.default_aggregates': "Vises altid: {list}.",
|
|
451
|
+
'reset.link': "Glemt adgangskode?",
|
|
452
|
+
'reset.title': "Nulstil din adgangskode",
|
|
453
|
+
'reset.intro': "Indtast dit brugernavn eller din e-mailadresse. Hvis det hører til en konto, sender vi et link, hvor du kan vælge en ny adgangskode.",
|
|
454
|
+
'reset.login_label': "Brugernavn eller e-mail",
|
|
455
|
+
'reset.send': "Send link",
|
|
456
|
+
'reset.sent': "Hvis det passer til en konto, er en e-mail med et link på vej. Linket virker én gang i 30 minutter.",
|
|
457
|
+
'reset.mail_subject': "Vælg en ny adgangskode til {app}",
|
|
458
|
+
'reset.mail_body': "Hej #USER#\n\nNogen, forhåbentlig dig, har bedt om at vælge en ny adgangskode til {app}. Åbn dette link inden for 30 minutter for at gøre det:\n\n{link}\n\nHvis du ikke har bedt om det, kan du se bort fra denne e-mail: din adgangskode forbliver uændret.",
|
|
459
|
+
'reset.new_title': "Vælg en ny adgangskode",
|
|
460
|
+
'reset.invalid': "Dette link er ikke (længere) gyldigt. Bed om et nyt.",
|
|
461
|
+
'reset.done': "Din adgangskode er ændret. Log ind med den nye.",
|
|
462
|
+
'reset.back': "Tilbage til log ind",
|
|
463
|
+
'grid.view_grid': "Gitter",
|
|
464
|
+
'grid.view_row': "Enkeltrækvisning",
|
|
465
|
+
'grid.row_of': "Række {row} af {total}",
|
|
466
|
+
'grid.first': "Første",
|
|
467
|
+
'grid.last': "Sidste",
|
|
451
468
|
'chart.other': "Andet",
|
|
452
469
|
'chart.label': "Etiket",
|
|
453
470
|
'chart.bubble': "Boble",
|
package/src/i18n/de.ts
CHANGED
|
@@ -562,6 +562,23 @@ export const de: Record<MessageKey, string> = {
|
|
|
562
562
|
'grid.column_position': 'Position von {column}',
|
|
563
563
|
'grid.column_width': 'Breite von {column} in Pixeln',
|
|
564
564
|
'grid.default_aggregates': 'Immer angezeigt: {list}.',
|
|
565
|
+
'reset.link': "Passwort vergessen?",
|
|
566
|
+
'reset.title': "Passwort zurücksetzen",
|
|
567
|
+
'reset.intro': "Geben Sie Ihren Benutzernamen oder Ihre E-Mail-Adresse ein. Gehört sie zu einem Konto, senden wir Ihnen einen Link, um ein neues Passwort zu wählen.",
|
|
568
|
+
'reset.login_label': "Benutzername oder E-Mail",
|
|
569
|
+
'reset.send': "Link senden",
|
|
570
|
+
'reset.sent': "Wenn das zu einem Konto passt, ist eine E-Mail mit einem Link unterwegs. Der Link funktioniert einmal, 30 Minuten lang.",
|
|
571
|
+
'reset.mail_subject': "Neues Passwort für {app} wählen",
|
|
572
|
+
'reset.mail_body': "Hallo #USER#,\n\njemand, hoffentlich Sie, hat ein neues Passwort für {app} angefordert. Öffnen Sie innerhalb von 30 Minuten diesen Link, um eines zu wählen:\n\n{link}\n\nWenn Sie das nicht angefordert haben, können Sie diese E-Mail ignorieren: Ihr Passwort bleibt unverändert.",
|
|
573
|
+
'reset.new_title': "Neues Passwort wählen",
|
|
574
|
+
'reset.invalid': "Dieser Link ist nicht (mehr) gültig. Fordern Sie einen neuen an.",
|
|
575
|
+
'reset.done': "Ihr Passwort wurde geändert. Melden Sie sich mit dem neuen an.",
|
|
576
|
+
'reset.back': "Zurück zur Anmeldung",
|
|
577
|
+
'grid.view_grid': 'Raster',
|
|
578
|
+
'grid.view_row': 'Einzelzeilenansicht',
|
|
579
|
+
'grid.row_of': 'Zeile {row} von {total}',
|
|
580
|
+
'grid.first': 'Erste',
|
|
581
|
+
'grid.last': 'Letzte',
|
|
565
582
|
'logic.branch_bad_url': 'Die Verzweigung "{name}" hat eine URL geliefert, die kein Pfad innerhalb der Anwendung ist.',
|
|
566
583
|
'logic.branch_no_app': 'Die Verzweigung "{name}" führt zu Seite {page} der Anwendung {app}, die nicht existiert.',
|
|
567
584
|
'download.none': 'Es gibt keine Datei zum Herunterladen.',
|