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/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 owner.tx((c) => step(c, String(id), at));
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 owner.tx((c) => step(c, String(id), at, done));
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 c.query(stmt);
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 c.query(`select (${applyBinds(cs.when, binds())})::boolean as ok`)).rows[0].ok);
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 c.query({ text: applyBinds(s.owners!.trim().replace(/;+\s*$/, ''), binds()), rowMode: 'array' })).rows.map((r) => String(r[0])).filter(Boolean))
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
+ }