pgkiln 0.36.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/090_workspace_isolation.sql +512 -0
- package/docs/apex-feature-parity.md +6 -6
- package/docs/guide/03-builder.md +49 -6
- package/docs/guide/12-development.md +6 -2
- package/examples/hr/hr_40_sample_data.sql +1 -1
- package/package.json +1 -1
- package/src/app.ts +18 -0
- package/src/automations.ts +3 -2
- package/src/builder/advisor.ts +11 -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/dataload.ts +24 -14
- package/src/builder/ddl-tools.ts +7 -4
- package/src/builder/documents.ts +2 -1
- package/src/builder/home.ts +6 -2
- package/src/builder/newapp.ts +21 -3
- package/src/builder/quicksql.ts +3 -2
- package/src/builder/report-settings.ts +4 -2
- package/src/builder/routes.ts +2 -2
- package/src/builder/sampledata.ts +22 -18
- package/src/builder/scripts.ts +27 -16
- package/src/builder/sql.ts +15 -10
- package/src/builder/unload.ts +3 -2
- package/src/builder/workspaces.ts +175 -15
- package/src/db.ts +36 -6
- package/src/ddl.ts +2 -1
- package/src/debug.ts +3 -2
- package/src/migrate.ts +3 -0
- package/src/process-jobs.ts +7 -2
- package/src/restsync.ts +3 -2
- package/src/runtime/routes.ts +4 -1
- package/src/sampledata.ts +3 -3
- package/src/session.ts +5 -2
- package/src/websources.ts +3 -1
- package/src/workflow.ts +65 -9
- package/src/workspace-db.ts +166 -0
package/src/workflow.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import pg from 'pg';
|
|
2
2
|
import { applyBinds, splitStatements, type BindValues } from './binds.ts';
|
|
3
3
|
import { owner } from './db.ts';
|
|
4
|
+
import { appIsolated, runtimeForApp } from './workspace-db.ts';
|
|
4
5
|
import { html, type Raw } from './html.ts';
|
|
5
6
|
import type { WebResponse } from './webclient.ts';
|
|
6
7
|
import { invoke, invokeCallProblems, invokeJson, isStringMap, toRows, valueAt, type InvokeConfig, type RestSource } from './websources.ts';
|
|
@@ -391,12 +392,12 @@ export async function runWorkflow(id: string | number): Promise<number> {
|
|
|
391
392
|
let more: boolean | PendingCall;
|
|
392
393
|
at.branch = null;
|
|
393
394
|
try {
|
|
394
|
-
more = await
|
|
395
|
+
more = await stepTx((c, side) => step(c, side, String(id), at));
|
|
395
396
|
if (typeof more === 'object') {
|
|
396
397
|
// an invoke_api step: the call runs between two transactions
|
|
397
398
|
const done = await makeCall(more);
|
|
398
399
|
at.branch = done.branch;
|
|
399
|
-
more = await
|
|
400
|
+
more = await stepTx((c, side) => step(c, side, String(id), at, done));
|
|
400
401
|
}
|
|
401
402
|
} catch (e) {
|
|
402
403
|
await fault(String(id), at.branch, (e as Error).message.slice(0, 2000));
|
|
@@ -426,8 +427,61 @@ async function fault(id: string, branch: string | null, msg: string) {
|
|
|
426
427
|
});
|
|
427
428
|
}
|
|
428
429
|
|
|
430
|
+
/**
|
|
431
|
+
* A step's application SQL in an isolated workspace (090) runs on the workspace's own login, not in
|
|
432
|
+
* the owner's transaction (where RESET ROLE would reach the owner). It commits just before the
|
|
433
|
+
* owner's transaction: if that one then fails, the step runs again (at least once).
|
|
434
|
+
*/
|
|
435
|
+
class AppSide {
|
|
436
|
+
private client: pg.PoolClient | null = null;
|
|
437
|
+
private release: (() => void) | null = null;
|
|
438
|
+
|
|
439
|
+
/** The connection for an isolated application's SQL (opened once per step), or null: use the owner's transaction. */
|
|
440
|
+
async open(w: { app_id: number; db_role: string | null; initiator: string; id: string; tenant_id: string }) {
|
|
441
|
+
if (this.client) return this.client;
|
|
442
|
+
if (!(await appIsolated(w.app_id))) return null;
|
|
443
|
+
const db = await runtimeForApp(w.app_id);
|
|
444
|
+
const c = await db.pool.connect();
|
|
445
|
+
this.client = c;
|
|
446
|
+
this.release = () => c.release();
|
|
447
|
+
await c.query('begin');
|
|
448
|
+
await c.query(
|
|
449
|
+
`select set_config('pgkiln.app_id', $1, true), set_config('pgkiln.app_user', $2, true), set_config('pgkiln.session_id', '', true),
|
|
450
|
+
set_config('pgkiln.workflow_id', $3, true), set_config('statement_timeout', '30s', true), set_config('pgkiln.tenant_id', $4, true)`,
|
|
451
|
+
[String(w.app_id), w.initiator, w.id, w.tenant_id],
|
|
452
|
+
);
|
|
453
|
+
if (w.db_role) await c.query(`set local role ${pg.escapeIdentifier(w.db_role)}`);
|
|
454
|
+
return c;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async commit() {
|
|
458
|
+
if (this.client) await this.client.query('commit');
|
|
459
|
+
this.close(false);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async close(rollback = true) {
|
|
463
|
+
if (this.client && rollback) await this.client.query('rollback').catch(() => {});
|
|
464
|
+
this.release?.();
|
|
465
|
+
this.client = null;
|
|
466
|
+
this.release = null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function stepTx<T>(fn: (c: pg.PoolClient, side: AppSide) => Promise<T>): Promise<T> {
|
|
471
|
+
const side = new AppSide();
|
|
472
|
+
try {
|
|
473
|
+
return await owner.tx(async (c) => {
|
|
474
|
+
const result = await fn(c, side);
|
|
475
|
+
await side.commit();
|
|
476
|
+
return result;
|
|
477
|
+
});
|
|
478
|
+
} finally {
|
|
479
|
+
await side.close();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
429
483
|
/** One step of the main path or of a branch, in the caller's transaction. Returns whether the workflow may go on right away. */
|
|
430
|
-
async function step(c: pg.PoolClient, id: string, at: { branch: string | null }, done?: DoneCall): Promise<boolean | PendingCall> {
|
|
484
|
+
async function step(c: pg.PoolClient, side: AppSide, id: string, at: { branch: string | null }, done?: DoneCall): Promise<boolean | PendingCall> {
|
|
431
485
|
const w = (
|
|
432
486
|
await c.query<Instance & { leased: boolean }>(
|
|
433
487
|
`select w.id::text, w.app_id, w.name, w.detail_pk, w.vars, w.steps, w.state, w.current_step, w.waiting_task::text,
|
|
@@ -486,9 +540,11 @@ async function step(c: pg.PoolClient, id: string, at: { branch: string | null },
|
|
|
486
540
|
});
|
|
487
541
|
// the application's SQL runs as its role; pgkiln's own bookkeeping as the owner
|
|
488
542
|
// (on an error the transaction is rolled back as a whole: resetting the role then would only hide the error)
|
|
489
|
-
const asApp = async <T>(fn: () => Promise<T>) => {
|
|
543
|
+
const asApp = async <T>(fn: (q: pg.PoolClient) => Promise<T>) => {
|
|
544
|
+
const isolated = await side.open(w);
|
|
545
|
+
if (isolated) return fn(isolated);
|
|
490
546
|
if (w.db_role) await c.query(`set local role ${pg.escapeIdentifier(w.db_role)}`);
|
|
491
|
-
const result = await fn();
|
|
547
|
+
const result = await fn(c);
|
|
492
548
|
if (w.db_role) await c.query('reset role');
|
|
493
549
|
return result;
|
|
494
550
|
};
|
|
@@ -602,9 +658,9 @@ async function step(c: pg.PoolClient, id: string, at: { branch: string | null },
|
|
|
602
658
|
switch (s.type) {
|
|
603
659
|
case 'sql': {
|
|
604
660
|
const statements = splitStatements(applyBinds(s.code, binds()));
|
|
605
|
-
const res = await asApp(async () => {
|
|
661
|
+
const res = await asApp(async (q) => {
|
|
606
662
|
let last: pg.QueryResult | undefined;
|
|
607
|
-
for (const stmt of statements) last = await
|
|
663
|
+
for (const stmt of statements) last = await q.query(stmt);
|
|
608
664
|
return last;
|
|
609
665
|
});
|
|
610
666
|
const row = res?.rows?.[0];
|
|
@@ -613,14 +669,14 @@ async function step(c: pg.PoolClient, id: string, at: { branch: string | null },
|
|
|
613
669
|
}
|
|
614
670
|
case 'switch': {
|
|
615
671
|
for (const cs of s.cases) {
|
|
616
|
-
const ok = await asApp(async () => (await
|
|
672
|
+
const ok = await asApp(async (q) => (await q.query(`select (${applyBinds(cs.when, binds())})::boolean as ok`)).rows[0].ok);
|
|
617
673
|
if (ok === true) return goTo(cs.next, `when ${cs.when}`);
|
|
618
674
|
}
|
|
619
675
|
return goTo(s.otherwise ?? undefined, 'otherwise');
|
|
620
676
|
}
|
|
621
677
|
case 'task': {
|
|
622
678
|
const owners = s.owners?.trim()
|
|
623
|
-
? await asApp(async () => (await
|
|
679
|
+
? await asApp(async (q) => (await q.query({ text: applyBinds(s.owners!.trim().replace(/;+\s*$/, ''), binds()), rowMode: 'array' })).rows.map((r) => String(r[0])).filter(Boolean))
|
|
624
680
|
: [];
|
|
625
681
|
const params = Object.fromEntries(Object.entries(vars).map(([key, v]) => [key, v]));
|
|
626
682
|
const taskId = (await c.query(`select meta.create_task($1, $2, $3, $4) as id`, [s.task, w.detail_pk, params, owners])).rows[0].id;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { makePool, onClosePools, owner, runtimeScope, runtimeUrl, setAppRuntime, sharedRuntime, type Db } from './db.ts';
|
|
3
|
+
import { secretKeyConfigured, SecretKeyMissing } from './secrets.ts';
|
|
4
|
+
|
|
5
|
+
// Isolated workspaces (090). An isolated workspace's applications run on its
|
|
6
|
+
// own login role (run_role) and its developers' SQL on another (dev_role), so
|
|
7
|
+
// SQL that resets its role falls back to that login, never to the shared
|
|
8
|
+
// runtime role or the owner. Row-level security on meta keys on the login
|
|
9
|
+
// (session_user). This module keeps a pool per login, opened on first use,
|
|
10
|
+
// and picks the runtime pool for an application:
|
|
11
|
+
//
|
|
12
|
+
// - a request under /a/:alias runs in runtimeScope (app.ts), so every
|
|
13
|
+
// runtime.query/one/tx of the request uses the workspace's pool;
|
|
14
|
+
// - background work for one application wraps itself in withAppDb(appId).
|
|
15
|
+
//
|
|
16
|
+
// The logins' passwords are derived from PGKILN_SECRET_KEY (every server
|
|
17
|
+
// process derives the same one) and set with ALTER ROLE when a pool opens.
|
|
18
|
+
|
|
19
|
+
interface Isolated {
|
|
20
|
+
ws: number;
|
|
21
|
+
runRole: string;
|
|
22
|
+
devRole: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// every application with its isolated workspace (null: a shared one); an application that isn't
|
|
26
|
+
// listed was made since the last load (e.g. just now, in an isolated workspace): load again
|
|
27
|
+
let byApp = new Map<number, Isolated | null>();
|
|
28
|
+
let byAlias = new Map<string, Isolated | null>();
|
|
29
|
+
let loadedAt = 0;
|
|
30
|
+
let loading: Promise<void> | null = null;
|
|
31
|
+
/** How long the list is trusted (other server processes' changes: moving applications, isolating workspaces). */
|
|
32
|
+
const TTL_MS = Number(process.env.PGKILN_WORKSPACE_CACHE_MS ?? 5000);
|
|
33
|
+
/** At most this often again for an application the list doesn't know (unknown aliases in URLs). */
|
|
34
|
+
const MISS_MS = 250;
|
|
35
|
+
|
|
36
|
+
async function load() {
|
|
37
|
+
const rows = (
|
|
38
|
+
await owner.query<{ app_id: number; alias: string; ws: number | null; run_role: string | null; dev_role: string | null }>(
|
|
39
|
+
`select a.id as app_id, a.alias, w.id as ws, w.run_role, w.dev_role
|
|
40
|
+
from meta.app a left join meta.workspace w on w.id = meta.app_workspace(a.id) and w.isolated`,
|
|
41
|
+
)
|
|
42
|
+
).rows;
|
|
43
|
+
const apps = new Map<number, Isolated | null>();
|
|
44
|
+
const aliases = new Map<string, Isolated | null>();
|
|
45
|
+
for (const r of rows) {
|
|
46
|
+
const iso = r.ws !== null && r.run_role && r.dev_role ? { ws: r.ws, runRole: r.run_role, devRole: r.dev_role } : null;
|
|
47
|
+
apps.set(r.app_id, iso);
|
|
48
|
+
aliases.set(r.alias.toLowerCase(), iso);
|
|
49
|
+
}
|
|
50
|
+
byApp = apps;
|
|
51
|
+
byAlias = aliases;
|
|
52
|
+
loadedAt = Date.now();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function reload(maxAge: number) {
|
|
56
|
+
if (Date.now() - loadedAt < maxAge) return;
|
|
57
|
+
loading ??= load().finally(() => (loading = null));
|
|
58
|
+
await loading;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The isolated workspace of an application (by id or alias), or null for a shared one. */
|
|
62
|
+
async function isolatedOf<K>(map: () => Map<K, Isolated | null>, key: K): Promise<Isolated | null> {
|
|
63
|
+
await reload(TTL_MS);
|
|
64
|
+
if (!map().has(key)) await reload(MISS_MS);
|
|
65
|
+
return map().get(key) ?? null;
|
|
66
|
+
}
|
|
67
|
+
const isoOfApp = (appId: number | string) => isolatedOf(() => byApp, Number(appId));
|
|
68
|
+
const isoOfAlias = (alias: string) => isolatedOf(() => byAlias, alias.toLowerCase());
|
|
69
|
+
|
|
70
|
+
/** Forget the cached list (after this process isolated a workspace or moved an application). */
|
|
71
|
+
export async function workspacesChanged() {
|
|
72
|
+
loadedAt = 0;
|
|
73
|
+
await reload(0);
|
|
74
|
+
// logins of workspaces that are no longer isolated: close their pools
|
|
75
|
+
const live = new Set([...byApp.values()].flatMap((i) => (i ? [i.runRole, i.devRole] : [])));
|
|
76
|
+
for (const [role, db] of pools) {
|
|
77
|
+
if (live.has(role)) continue;
|
|
78
|
+
pools.delete(role);
|
|
79
|
+
await (await db).end().catch(() => {});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The password of a workspace login: the same in every server process, never stored. */
|
|
84
|
+
export function loginPassword(role: string) {
|
|
85
|
+
if (!secretKeyConfigured()) throw new SecretKeyMissing();
|
|
86
|
+
return createHmac('sha256', `pgkiln-workspace-login\0${process.env.PGKILN_SECRET_KEY}`).update(role).digest('base64url');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function urlFor(role: string) {
|
|
90
|
+
const u = new URL(runtimeUrl);
|
|
91
|
+
u.username = encodeURIComponent(role);
|
|
92
|
+
u.password = encodeURIComponent(loginPassword(role));
|
|
93
|
+
return u.toString();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const pools = new Map<string, Promise<Db>>();
|
|
97
|
+
const POOL_SIZE = Number(process.env.PGKILN_WORKSPACE_POOL_SIZE ?? 5);
|
|
98
|
+
|
|
99
|
+
/** The pool of a workspace login role, opened (and its password set) on first use. */
|
|
100
|
+
function poolFor(role: string): Promise<Db> {
|
|
101
|
+
let p = pools.get(role);
|
|
102
|
+
if (!p) {
|
|
103
|
+
p = (async () => {
|
|
104
|
+
// the role is pgkiln's own (named by meta.workspace_isolate): set the password it expects
|
|
105
|
+
if (!LOGIN.test(role)) throw new Error(`Not a workspace login role: ${role}`);
|
|
106
|
+
const password = loginPassword(role);
|
|
107
|
+
await owner.query(`alter role ${quoteIdent(role)} password ${quoteLiteral(password)}`);
|
|
108
|
+
return makePool(urlFor(role), `pgkiln-${role}`, POOL_SIZE);
|
|
109
|
+
})();
|
|
110
|
+
p.catch(() => pools.delete(role));
|
|
111
|
+
pools.set(role, p);
|
|
112
|
+
}
|
|
113
|
+
return p;
|
|
114
|
+
}
|
|
115
|
+
onClosePools(async () => {
|
|
116
|
+
const all = [...pools.values()];
|
|
117
|
+
pools.clear();
|
|
118
|
+
await Promise.all(all.map(async (p) => (await p.catch(() => null))?.end()));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const LOGIN = /^pgkiln_ws\d{1,9}_(run|dev)_[0-9a-f]{8}$/;
|
|
122
|
+
const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`;
|
|
123
|
+
const quoteLiteral = (s: string) => `'${s.replace(/'/g, "''")}'`;
|
|
124
|
+
|
|
125
|
+
/** The runtime pool of an application: its isolated workspace's login, or the shared one. */
|
|
126
|
+
export async function runtimeForApp(appId: number | string): Promise<Db> {
|
|
127
|
+
const iso = await isoOfApp(appId);
|
|
128
|
+
return iso ? poolFor(iso.runRole) : sharedRuntime;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
setAppRuntime(runtimeForApp);
|
|
132
|
+
|
|
133
|
+
/** The runtime pool for an application alias (a request under /a/:alias). */
|
|
134
|
+
export async function runtimeForAlias(alias: string): Promise<Db> {
|
|
135
|
+
const iso = await isoOfAlias(alias);
|
|
136
|
+
return iso ? poolFor(iso.runRole) : sharedRuntime;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Run fn with runtime.* on the application's own connection (background work for one application). */
|
|
140
|
+
export async function withAppDb<T>(appId: number | string, fn: () => Promise<T>): Promise<T> {
|
|
141
|
+
const db = await runtimeForApp(appId);
|
|
142
|
+
return runtimeScope.run(db, fn);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Is the application in an isolated workspace? */
|
|
146
|
+
export async function appIsolated(appId: number | string) {
|
|
147
|
+
return !!(await isoOfApp(appId));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The connection for a developer's SQL about an application (the Advisor, the
|
|
152
|
+
* code editor, region settings, supporting objects, plug-in install SQL): the
|
|
153
|
+
* owner, or an isolated workspace's developer login. Callers SET LOCAL ROLE to
|
|
154
|
+
* the application's role as before; RESET ROLE then only reaches the login.
|
|
155
|
+
*/
|
|
156
|
+
export async function devDbForApp(appId: number | string | null | undefined): Promise<Db> {
|
|
157
|
+
if (appId == null || appId === '') return owner;
|
|
158
|
+
const iso = await isoOfApp(appId);
|
|
159
|
+
return iso ? poolFor(iso.devRole) : owner;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The developer login of an isolated workspace (the SQL Workshop of its developers), or null. */
|
|
163
|
+
export async function devDbForWorkspace(ws: number): Promise<Db | null> {
|
|
164
|
+
const r = await owner.one<{ dev_role: string }>('select dev_role from meta.workspace where id = $1 and isolated', [ws]);
|
|
165
|
+
return r ? poolFor(r.dev_role) : null;
|
|
166
|
+
}
|