pgkiln 0.36.0 → 0.38.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 (54) hide show
  1. package/SECURITY.md +12 -9
  2. package/db/migrations/090_workspace_isolation.sql +512 -0
  3. package/db/migrations/091_workspace_service.sql +20 -0
  4. package/db/migrations/092_app_groups.sql +23 -0
  5. package/docs/apex-feature-parity.md +16 -11
  6. package/docs/guide/03-builder.md +125 -11
  7. package/docs/guide/12-development.md +12 -2
  8. package/examples/gallery/README.md +13 -0
  9. package/examples/gallery/gallery.json +20 -0
  10. package/examples/gallery/starters/asset-inventory.json +30 -0
  11. package/examples/gallery/starters/contacts.json +41 -0
  12. package/examples/gallery/starters/event-registrations.json +29 -0
  13. package/examples/gallery/starters/expense-claims.json +32 -0
  14. package/examples/gallery/starters/team-tasks.json +35 -0
  15. package/examples/hr/hr_40_sample_data.sql +1 -1
  16. package/package.json +1 -1
  17. package/public/builder.css +91 -0
  18. package/src/app.ts +24 -0
  19. package/src/automations.ts +3 -2
  20. package/src/builder/advisor.ts +11 -5
  21. package/src/builder/ai-builder.ts +27 -18
  22. package/src/builder/app-groups.ts +132 -0
  23. package/src/builder/blueprints.ts +21 -7
  24. package/src/builder/code-editor.ts +3 -2
  25. package/src/builder/cross-app.ts +106 -0
  26. package/src/builder/dataload.ts +24 -14
  27. package/src/builder/ddl-tools.ts +7 -4
  28. package/src/builder/documents.ts +2 -1
  29. package/src/builder/gallery.ts +154 -0
  30. package/src/builder/home.ts +33 -10
  31. package/src/builder/newapp.ts +21 -3
  32. package/src/builder/quicksql.ts +3 -2
  33. package/src/builder/report-settings.ts +4 -2
  34. package/src/builder/routes.ts +78 -26
  35. package/src/builder/sampledata.ts +22 -18
  36. package/src/builder/scripts.ts +31 -16
  37. package/src/builder/sql.ts +119 -15
  38. package/src/builder/ui.ts +30 -7
  39. package/src/builder/unload.ts +3 -2
  40. package/src/builder/workspaces.ts +291 -22
  41. package/src/cli/replace.ts +2 -0
  42. package/src/db.ts +36 -6
  43. package/src/ddl.ts +2 -1
  44. package/src/debug.ts +3 -2
  45. package/src/migrate.ts +3 -0
  46. package/src/process-jobs.ts +7 -2
  47. package/src/restsync.ts +3 -2
  48. package/src/runtime/calendar.ts +2 -1
  49. package/src/runtime/routes.ts +4 -1
  50. package/src/sampledata.ts +3 -3
  51. package/src/session.ts +18 -4
  52. package/src/websources.ts +3 -1
  53. package/src/workflow.ts +65 -9
  54. package/src/workspace-db.ts +166 -0
@@ -437,6 +437,41 @@ kbd { display: inline-block; padding: 0 0.35em; border: 1px solid var(--border);
437
437
  .ide-login { background: linear-gradient(180deg, var(--chrome) 0 40%, var(--bg) 40%); color: var(--text); }
438
438
  .ide-login .btn-hot { background: var(--hot); border-color: var(--hot); color: var(--hot-text); }
439
439
  .ide-login .btn-hot:hover { background: var(--hot-hover); }
440
+ .signin-body.ide-login { display: block; background: var(--bg); }
441
+ .signin { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); width: 100%; max-width: none; min-height: 100vh; min-height: 100dvh; margin: 0; padding: 0; }
442
+ .signin-hero {
443
+ display: flex; flex-direction: column; justify-content: flex-start; gap: 0.75rem; padding: 3rem;
444
+ color: var(--chrome-text); background-color: var(--chrome);
445
+ background-image: repeating-linear-gradient(135deg, rgb(255 255 255 / 0.025) 0 2px, transparent 2px 14px),
446
+ repeating-linear-gradient(45deg, rgb(255 255 255 / 0.02) 0 1px, transparent 1px 22px);
447
+ }
448
+ .signin-logo { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 8px; background: var(--hot); color: var(--hot-text); font-weight: 700; font-size: 18px; margin-bottom: 2rem; }
449
+ .signin-title { margin: 0; font-size: clamp(2rem, 4vw, 2.75rem); font-weight: 750; color: var(--chrome-text); }
450
+ .signin-tagline { margin: 0; font-size: 1.2rem; color: var(--chrome-muted); max-width: 30ch; }
451
+ .signin-foot { margin: auto 0 0; font-size: 0.85rem; color: var(--chrome-muted); }
452
+ .signin-panel { display: grid; place-items: center; padding: 2rem 1rem; background: var(--surface); }
453
+ .signin-form { display: flex; flex-direction: column; gap: 0.85rem; width: 100%; max-width: 420px; }
454
+ .signin-form h2 { margin: 0; font-size: 1.5rem; }
455
+ .signin-form .field { margin: 0; }
456
+ .signin-input { display: flex; align-items: center; gap: 0.6rem; padding: 0 0.75rem; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); }
457
+ .signin-input:focus-within { border-color: var(--accent); outline: 2px solid var(--accent); outline-offset: -1px; }
458
+ .signin-input > .icon { flex: none; width: 18px; height: 18px; color: var(--muted); }
459
+ .ide-login .signin-input input { flex: 1; width: auto; min-width: 0; border: 0; border-radius: 0; box-shadow: none; background: transparent; padding: 0.7rem 0; font-size: 1rem; color: var(--text); outline: none; }
460
+ .signin-reveal { flex: none; border: 0; background: none; color: var(--accent); cursor: pointer; font-size: 0.85rem; padding: 0.25rem; }
461
+ .signin-remember { font-size: 0.9rem; }
462
+ .signin-submit { padding: 0.75rem; font-size: 1rem; }
463
+ .signin-hint { margin: 0; text-align: center; }
464
+ .signin-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem 1.5rem; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
465
+ .signin-actions a { display: inline-flex; align-items: center; gap: 0.4rem; color: var(--gold); text-decoration: none; }
466
+ .signin-actions a:hover { text-decoration: underline; }
467
+ @media (max-width: 760px) {
468
+ .signin { grid-template-columns: 1fr; }
469
+ .signin-hero { padding: 1.25rem 1rem; gap: 0.25rem; }
470
+ .signin-logo { margin-bottom: 0.5rem; width: 36px; height: 36px; font-size: 15px; }
471
+ .signin-tagline { font-size: 1rem; }
472
+ .signin-foot { display: none; }
473
+ .signin-panel { place-items: start center; }
474
+ }
440
475
 
441
476
  /* ------------------------------------------------------------ small screens */
442
477
  /* ------------------------------------------------------------ status bar (every builder page, like APEX's footer) */
@@ -781,3 +816,59 @@ details.prop-group-more[open] > summary { margin-bottom: 8px; }
781
816
  .pe-help-body { padding-top: 0.4rem; overflow-wrap: anywhere; }
782
817
  .pe-help-body h3 { font-size: 0.95rem; margin: 0 0 0.3rem; }
783
818
  .pe-help-body p { margin: 0 0 0.4rem; }
819
+
820
+ /* ------------------------------------------------------------ environment banner, workspace message (091), Administration menu */
821
+ .ide-banner { padding: 3px 12px; font-size: 12px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; text-align: center; }
822
+ .ide-banner-red { background: #b42318; color: #fff; }
823
+ .ide-banner-orange { background: #c4520f; color: #fff; }
824
+ .ide-banner-yellow { background: #e6b400; color: #1e1d1b; }
825
+ .ide-banner-green { background: #2f7d32; color: #fff; }
826
+ .ide-banner-teal { background: #0e7a74; color: #fff; }
827
+ .ide-banner-blue { background: #1a5f99; color: #fff; }
828
+ .ide-banner-purple { background: #6941c6; color: #fff; }
829
+ .ide-banner-gray { background: #5e5a54; color: #fff; }
830
+ .ab-message { display: flex; gap: 0.6rem; align-items: flex-start; margin-bottom: 12px; }
831
+ .ab-message p { margin: 0.15rem 0 0; white-space: pre-line; }
832
+ .rail-admin { padding: 0; }
833
+ .rail-admin > summary { list-style: none; cursor: pointer; }
834
+ .rail-admin > summary::-webkit-details-marker { display: none; }
835
+ .rail-admin .menu-panel { bottom: 56px; }
836
+ .rail-admin .menu-links strong { padding: 2px 0 4px; }
837
+ .ide .alert-info { background: var(--accent-soft); border-color: var(--accent); color: var(--text); }
838
+
839
+ /* ------------------------------------------------------------ SQL Workshop home (sprint 61) */
840
+ .sw-tiles { grid-template-columns: repeat(5, minmax(0, 1fr)); }
841
+ .sw-tile-icon { background: #2f7d32; }
842
+ .sw-recents { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
843
+ .sw-recent { min-width: 0; border-left: 1px solid var(--border); }
844
+ .sw-recent:first-child { border-left: 0; }
845
+ .sw-recent h2 { margin: 0; padding: 10px 12px; font-size: 13.5px; border-bottom: 1px solid var(--border); }
846
+ .sw-recent ul { list-style: none; margin: 0; padding: 0; max-height: 440px; overflow-y: auto; }
847
+ .sw-recent li { display: flex; justify-content: space-between; gap: 12px; padding: 7px 12px; border-bottom: 1px solid var(--border); }
848
+ .sw-recent li a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--gold); text-decoration: none; }
849
+ .sw-recent li a:hover { text-decoration: underline; }
850
+ .sw-recent li .muted { flex: none; font-size: 12px; }
851
+ .sw-recent > p { padding: 0 12px; }
852
+ .sw-schema { display: flex; gap: 6px; }
853
+ .sw-schema select { flex: 1; min-width: 0; }
854
+ @media (max-width: 1100px) { .sw-tiles { grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr)); } .sw-recents { grid-template-columns: 1fr; } .sw-recent { border-left: 0; border-top: 1px solid var(--border); } .sw-recent:first-child { border-top: 0; } }
855
+
856
+ /* ------------------------------------------------------------ App Gallery (sprint 61) */
857
+ .gal-bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 8px; }
858
+ .gal-search { flex: 1 1 320px; }
859
+ .gal-tabs { display: inline-flex; }
860
+ .gal-tabs a { padding: 6px 12px; text-decoration: none; color: var(--text); border: 1px solid var(--border); background: var(--surface); }
861
+ .gal-tabs a + a { border-left: 0; }
862
+ .gal-tabs a:first-child { border-radius: var(--radius) 0 0 var(--radius); }
863
+ .gal-tabs a:last-child { border-radius: 0 var(--radius) var(--radius) 0; }
864
+ .gal-tabs a[aria-current] { background: var(--surface-3); font-weight: 650; }
865
+ .gal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr)); gap: 12px; }
866
+ .gal-card { display: flex; flex-direction: column; gap: 10px; padding: 16px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); min-width: 0; }
867
+ .gal-head { display: flex; gap: 12px; align-items: center; }
868
+ .gal-head h2 { margin: 0; font-size: 15px; }
869
+ .gal-icon { flex: none; display: grid; place-items: center; width: 48px; height: 48px; border-radius: 8px; background: var(--accent-soft); color: var(--accent); }
870
+ .gal-icon .icon { width: 26px; height: 26px; }
871
+ .gal-card > p { margin: 0; flex: 1; line-height: 1.5; }
872
+ .gal-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; }
873
+ .gal-foot form { margin: 0; }
874
+ .ab-group select { min-width: 140px; }
package/src/app.ts CHANGED
@@ -65,6 +65,11 @@ import { ownerUrl, runtime } from './db.ts';
65
65
  import { refreshInstanceSettings } from './instance.ts';
66
66
  import { loadSecrets, securityHeaders, trustProxySetting } from './security.ts';
67
67
  import { lucideSymbol } from './icons.ts';
68
+ import { runtimeForAlias } from './workspace-db.ts';
69
+ import { galleryRoutes } from './builder/gallery.ts';
70
+ import { appGroupRoutes } from './builder/app-groups.ts';
71
+ import { crossAppRoutes } from './builder/cross-app.ts';
72
+ import { runtimeScope } from './db.ts';
68
73
 
69
74
  export async function buildApp(opts: { logger?: boolean } = {}) {
70
75
  // a database older than the code: apply the migrations (MIGRATE_ON_START=true)
@@ -104,6 +109,22 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
104
109
  );
105
110
  });
106
111
  }
112
+ // an application of an isolated workspace runs on its workspace's own login (src/workspace-db.ts):
113
+ // the rest of the request runs in that scope, so runtime.* uses that pool
114
+ app.addHook('onRequest', (req, _reply, done) => {
115
+ const m = /^\/a\/([^/?#]{1,100})/.exec(req.url);
116
+ if (!m || pending.length) return done();
117
+ let alias: string;
118
+ try {
119
+ alias = decodeURIComponent(m[1]);
120
+ } catch {
121
+ return done();
122
+ }
123
+ runtimeForAlias(alias).then(
124
+ (db) => runtimeScope.run(db, done),
125
+ (err: Error) => done(err),
126
+ );
127
+ });
107
128
  // debug messages: stored after the response, for requests that have a debug log (src/debug.ts)
108
129
  app.addHook('onError', async (req, _reply, err) => {
109
130
  debugOf(req)?.add(1, 'error', `unhandled: ${err.message}`);
@@ -153,6 +174,9 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
153
174
  await app.register(staticFileRoutes);
154
175
  await app.register(restRoutes);
155
176
  await app.register(builderRoutes);
177
+ await app.register(galleryRoutes);
178
+ await app.register(appGroupRoutes);
179
+ await app.register(crossAppRoutes);
156
180
  await app.register(sharedRoutes);
157
181
  await app.register(designerRoutes);
158
182
  await app.register(lockRoutes);
@@ -1,5 +1,6 @@
1
1
  import pg from 'pg';
2
2
  import { owner, runtime } from './db.ts';
3
+ import { withAppDb } from './workspace-db.ts';
3
4
 
4
5
  // Automations (Shared Components → Automations): SQL or PL/pgSQL on a cron
5
6
  // schedule, run by the pgkiln server as the application's database role.
@@ -226,7 +227,7 @@ export async function runAutomation(id: number, trigger: 'schedule' | 'manual' =
226
227
 
227
228
  /** The actions, as the application's role, through meta.automation_execute() (also used by meta.run_automation). */
228
229
  async function execute(def: Definition): Promise<{ rows: number; failed: number; errors: RowError[] }> {
229
- return runtime.tx(async (c) => {
230
+ return withAppDb(def.app_id, () => runtime.tx(async (c) => {
230
231
  await c.query(
231
232
  `select set_config('pgkiln.app_user', $1, true), set_config('pgkiln.app_id', $2, true),
232
233
  set_config('pgkiln.automation_id', $3, true), set_config('pgkiln.session_id', '', true),
@@ -235,7 +236,7 @@ async function execute(def: Definition): Promise<{ rows: number; failed: number;
235
236
  );
236
237
  if (def.db_role) await c.query(`set local role ${pg.escapeIdentifier(def.db_role)}`);
237
238
  return (await c.query('select meta.automation_execute($1::jsonb) as r', [JSON.stringify(def)])).rows[0].r;
238
- });
239
+ }));
239
240
  }
240
241
 
241
242
  // ------------------------------------------------------------------ scheduler
@@ -1,7 +1,8 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
2
  import pg from 'pg';
3
3
  import { applyBinds, splitStatements } from '../binds.ts';
4
- import { owner } from '../db.ts';
4
+ import { owner, type Db } from '../db.ts';
5
+ import { devDbForApp } from '../workspace-db.ts';
5
6
  import { html, type Raw } from '../html.ts';
6
7
  import { icon } from '../icons.ts';
7
8
  import { templateProblem } from '../runtime/document.ts';
@@ -156,7 +157,7 @@ const BUILT_IN = new Set(['MUST_NOT_BE_PUBLIC_USER']);
156
157
  * e.g. an import that hasn't committed yet) everything is read and checked
157
158
  * on that client, inside a savepoint that is rolled back.
158
159
  */
159
- export async function advise(appId: number, tx?: pg.ClientBase): Promise<{ findings: Finding[]; checked: number; plpgsqlCheck: boolean }> {
160
+ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db } = {}): Promise<{ findings: Finding[]; checked: number; plpgsqlCheck: boolean }> {
160
161
  const query = (sql: string, params: unknown[] = []) => (tx ? tx.query(sql, params) : owner.query(sql, params));
161
162
  const one = async (sql: string, params: unknown[] = []) => (await query(sql, params)).rows[0];
162
163
  const app = await one('select * from meta.app where id = $1', [appId]);
@@ -188,9 +189,14 @@ export async function advise(appId: number, tx?: pg.ClientBase): Promise<{ findi
188
189
  and not exists (select 1 from meta.page_group g where g.app_id = p.app_id and g.name = p.page_group) order by 1`, [appId])).rows)
189
190
  findings.push({ severity: 'warning', entry: null, field: `Page ${p.page_no} → Page group`, message: `Page group ${p.page_group} doesn't exist (Shared Components → Page groups).` });
190
191
  // ---- SQL, as the application's role, rolled back (a savepoint in the caller's transaction)
191
- const c = (tx ?? (await owner.pool.connect())) as pg.PoolClient;
192
+ // (090) an isolated workspace's application: planned on the workspace's developer login, in a
193
+ // transaction of its own (planning can run the application's functions; RESET ROLE there only
194
+ // reaches that login, never the owner); `opts.db` names the login for an import into the workspace
195
+ const own = opts.db && opts.db !== owner ? opts.db : await devDbForApp(appId);
196
+ const inTx = !!tx && own === owner;
197
+ const c = (inTx ? tx : await (own === owner ? owner : own).pool.connect()) as pg.PoolClient;
192
198
  try {
193
- await c.query(tx ? 'savepoint advisor_all' : 'begin');
199
+ await c.query(inTx ? 'savepoint advisor_all' : 'begin');
194
200
  await c.query(`set local statement_timeout = '5s'`);
195
201
  await c.query(`select set_config('pgkiln.app_id', $1, true)`, [String(appId)]);
196
202
  if (app.db_role) await c.query(`set local role ${pg.escapeIdentifier(app.db_role)}`);
@@ -263,7 +269,7 @@ export async function advise(appId: number, tx?: pg.ClientBase): Promise<{ findi
263
269
  }
264
270
  }
265
271
  } finally {
266
- if (tx) {
272
+ if (inTx) {
267
273
  await c.query('rollback to savepoint advisor_all');
268
274
  await c.query('release savepoint advisor_all');
269
275
  } else {
@@ -9,6 +9,7 @@ import type { Session } from '../session.ts';
9
9
  import { appOr404 } from './forms.ts';
10
10
  import { isAdmin } from './locks.ts';
11
11
  import { appHeader, back, BASE, csrf, developer, flash, region, select, send, shell, workshopTabs, type Req } from './ui.ts';
12
+ import { workshopDb } from './workspaces.ts';
12
13
  import { WIZARD_KINDS, wizardTables } from './wizards.ts';
13
14
 
14
15
  // App Builder AI (migration 062), with the AI service an administrator
@@ -43,10 +44,20 @@ async function ask(s: Session, req: Parameters<typeof generate>[1]) {
43
44
  return generate(svc.name, req, { appId: null, user: s.username, source: 'builder' });
44
45
  }
45
46
 
46
- /** Schemas a developer may describe or ask about (not pgkiln's, not the system's). */
47
- async function schemas() {
48
- return (await owner.query<{ n: string }>(
49
- `select nspname as n from pg_namespace where nspname !~ '^pg_' and nspname not in ('information_schema', 'meta') order by 1`)).rows.map((r) => r.n);
47
+ /** Schemas a developer may describe or ask about (not pgkiln's, not the system's; in an isolated workspace, its own). */
48
+ async function schemas(s: Session) {
49
+ const db = await workshopDb(s);
50
+ return (await db.query<{ n: string }>(
51
+ `select nspname as n from pg_namespace where nspname !~ '^pg_' and nspname not in ('information_schema', 'meta')
52
+ ${db === owner ? '' : `and has_schema_privilege(oid, 'USAGE')`} order by 1`)).rows.map((r) => r.n);
53
+ }
54
+
55
+ /** A table's descriptions as database comments. */
56
+ async function comment(c: { query: (sql: string) => Promise<unknown> }, t: TableInfo, notes: [string, string][]) {
57
+ const rel = `${pg.escapeIdentifier(t.schema)}.${pg.escapeIdentifier(t.table)}`;
58
+ const what = t.kind === 'view' ? 'view' : t.kind === 'materialized view' ? 'materialized view' : 'table';
59
+ for (const [col, note] of notes)
60
+ await c.query(col ? `comment on column ${rel}.${pg.escapeIdentifier(col)} is ${note ? pg.escapeLiteral(note) : 'null'}` : `comment on ${what} ${rel} is ${note ? pg.escapeLiteral(note) : 'null'}`);
50
61
  }
51
62
 
52
63
  interface TableInfo {
@@ -130,7 +141,7 @@ const SQL_SCHEMA = {
130
141
  };
131
142
 
132
143
  async function workshopPage(s: Session, reply: FastifyReply, out: { question?: string; schema?: string; sql?: string; error?: string; result?: Raw }) {
133
- const list = await schemas();
144
+ const list = await schemas(s);
134
145
  const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('ai')}
135
146
  ${await serviceBox(s)}
136
147
  <p><a href="${BASE}/sql/ai/describe">Describe tables for AI ▸</a> <span class="muted">(better answers: say what tables and columns mean)</span></p>
@@ -162,7 +173,7 @@ async function workshopPage(s: Session, reply: FastifyReply, out: { question?: s
162
173
  const isName = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && v.length <= 63;
163
174
 
164
175
  async function describePage(s: Session, reply: FastifyReply, schema: string, table: string, draft: { table?: string; columns?: Record<string, string> } | null, message?: Raw) {
165
- const list = await schemas();
176
+ const list = await schemas(s);
166
177
  const tables = schema && list.includes(schema) ? await tableInfo({ schema }) : [];
167
178
  const t = table ? tables.find((x) => x.table === table) : undefined;
168
179
  const noted = new Set((await owner.query<{ t: string }>(`select distinct table_name as t from meta.ai_table_note where schema_name = $1`, [schema])).rows.map((r) => r.t));
@@ -299,7 +310,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
299
310
  let result: Raw;
300
311
  try {
301
312
  if (!question) throw new AiError('config', 'Type a question.');
302
- if (!(await schemas()).includes(schema)) throw new AiError('config', 'Choose a schema.');
313
+ if (!(await schemas(s)).includes(schema)) throw new AiError('config', 'Choose a schema.');
303
314
  const res = await ask(s, {
304
315
  system: `You write PostgreSQL ${(await owner.one<{ v: number }>(`select current_setting('server_version_num')::int / 10000 as v`))!.v} SQL for a developer, for these tables and views:\n\n${describeTables(await tableInfo({ schema }))}\n\n` +
305
316
  'Use only those tables and columns, qualified with their schema. Write a SELECT unless the question clearly asks to change data. The developer reviews the SQL before anything runs.',
@@ -331,7 +342,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
331
342
  let result: Raw;
332
343
  try {
333
344
  if (!sql.trim() && !error.trim()) throw new AiError('config', 'Paste a query, an error message or both.');
334
- const tables = schema && (await schemas()).includes(schema) ? describeTables(await tableInfo({ schema })) : '';
345
+ const tables = schema && (await schemas(s)).includes(schema) ? describeTables(await tableInfo({ schema })) : '';
335
346
  const res = await ask(s, {
336
347
  system: 'You explain PostgreSQL to a developer: what a query does, why an error happens and how to fix it. Be concise; show corrected SQL when it helps.' +
337
348
  (tables ? `\n\nThe tables and views of the schema:\n\n${tables}` : ''),
@@ -352,10 +363,10 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
352
363
  });
353
364
 
354
365
  /** The posted table, checked against the catalog (null: unknown or reserved). */
355
- const postedTable = async (b: Record<string, any>) => {
366
+ const postedTable = async (s: Session, b: Record<string, any>) => {
356
367
  const schema = String(b.schema ?? '');
357
368
  const table = String(b.table ?? '');
358
- if (!isName(schema) || !isName(table) || RESERVED.test(schema)) return null;
369
+ if (!isName(schema) || !isName(table) || RESERVED.test(schema) || !(await schemas(s)).includes(schema)) return null;
359
370
  return (await tableInfo({ schema, table }))[0] ?? null;
360
371
  };
361
372
 
@@ -363,11 +374,14 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
363
374
  const s = await developer(req, reply);
364
375
  if (!s) return;
365
376
  const b = (req.body ?? {}) as Record<string, any>;
366
- const t = await postedTable(b);
377
+ const t = await postedTable(s, b);
367
378
  if (!t) return reply.code(404).send('Not found');
368
379
  const notes: [string, string][] = [['', String(b['note:'] ?? '')], ...t.columns.map((c): [string, string] => [c.name, String(b[`note:${c.name}`] ?? '')])]
369
380
  .map(([k, v]) => [k, v.replace(/\s+/g, ' ').trim().slice(0, 2000)]);
370
381
  try {
382
+ // database comments as the developer's SQL connection (an isolated workspace: its own login), then the notes
383
+ const db = await workshopDb(s);
384
+ if (b.comments === 'true' && db !== owner) await db.tx((c) => comment(c, t, notes));
371
385
  await owner.tx(async (c) => {
372
386
  for (const [col, note] of notes) {
373
387
  if (note) await c.query(
@@ -376,12 +390,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
376
390
  [t.schema, t.table, col, note, s.username]);
377
391
  else await c.query('delete from meta.ai_table_note where schema_name = $1 and table_name = $2 and column_name = $3', [t.schema, t.table, col]);
378
392
  }
379
- if (b.comments === 'true') {
380
- const rel = `${pg.escapeIdentifier(t.schema)}.${pg.escapeIdentifier(t.table)}`;
381
- const what = t.kind === 'view' ? 'view' : t.kind === 'materialized view' ? 'materialized view' : 'table';
382
- for (const [col, note] of notes)
383
- await c.query(col ? `comment on column ${rel}.${pg.escapeIdentifier(col)} is ${note ? pg.escapeLiteral(note) : 'null'}` : `comment on ${what} ${rel} is ${note ? pg.escapeLiteral(note) : 'null'}`);
384
- }
393
+ if (b.comments === 'true' && db === owner) await comment(c, t, notes);
385
394
  });
386
395
  flash(s, `Descriptions of ${t.schema}.${t.table} saved${b.comments === 'true' ? ', also as database comments' : ''}.`);
387
396
  } catch (e) {
@@ -394,7 +403,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
394
403
  const s = await developer(req, reply);
395
404
  if (!s) return;
396
405
  const b = (req.body ?? {}) as Record<string, any>;
397
- const t = await postedTable(b);
406
+ const t = await postedTable(s, b);
398
407
  if (!t) return reply.code(404).send('Not found');
399
408
  const names = t.columns.map((c) => c.name);
400
409
  try {
@@ -0,0 +1,132 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { html, raw } from '../html.ts';
4
+ import { icon } from '../icons.ts';
5
+ import { clientIp, logActivity } from '../session.ts';
6
+ import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
7
+ import { currentWorkspace } from './workspaces.ts';
8
+
9
+ // Application groups (092; APEX: Workspace Utilities → Application Groups): a
10
+ // workspace's named groups of applications. The App Builder home filters and
11
+ // labels applications by group. Every developer of the workspace manages them.
12
+
13
+ const NAME = /^\S(.{0,58}\S)?$/;
14
+ const crumbs = (extra: [string, string?][] = []): [string, string?][] => [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Application groups', `${BASE}/app-groups`], ...extra];
15
+
16
+ const groupError = (e: unknown) => {
17
+ const m = (e as Error).message;
18
+ if (m.includes('app_group_workspace_id_name_key')) return 'A group with this name exists already.';
19
+ if (m.includes('app_group_name_check')) return 'The name is 1 to 60 characters, without spaces at the start or end.';
20
+ return m;
21
+ };
22
+
23
+ export async function appGroupRoutes(app: FastifyInstance) {
24
+ app.get(`${BASE}/app-groups`, async (req: Req, reply) => {
25
+ const s = await developer(req, reply);
26
+ if (!s) return;
27
+ const ws = currentWorkspace(s);
28
+ const [groups, apps] = await Promise.all([
29
+ owner.query<{ id: number; name: string; description: string | null; apps: number }>(
30
+ `select g.id, g.name, g.description, (select count(*) from meta.app_group_member m where m.group_id = g.id)::int as apps
31
+ from meta.app_group g where g.workspace_id = $1 order by lower(g.name)`,
32
+ [ws],
33
+ ),
34
+ owner.query<{ id: number; name: string; alias: string; group_id: number | null }>(
35
+ `select a.id, a.name, a.alias, m.group_id from meta.app a left join meta.app_group_member m on m.app_id = a.id
36
+ where meta.app_workspace(a.id) = $1 order by lower(a.name), a.id`,
37
+ [ws],
38
+ ),
39
+ ]);
40
+ const main = html`<div class="title-row"><h1>Application groups</h1></div>
41
+ <p class="muted u-mt0">Groups organise the applications of this workspace (${s.workspace?.name ?? ''}) on the App Builder home, which can show one group at a time. They change nothing in the applications.</p>
42
+ <div class="columns wide-left">
43
+ ${region('Groups', groups.rows.length
44
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Group</th><th class="num">Applications</th><th>Rename</th><th></th></tr></thead><tbody>
45
+ ${groups.rows.map((g) => html`<tr>
46
+ <td data-label="Group"><a href="${BASE}?group=${g.id}">${g.name}</a>${g.description ? html`<br><span class="muted small">${g.description}</span>` : ''}</td>
47
+ <td class="num" data-label="Applications">${g.apps}</td>
48
+ <td data-label="Rename"><form method="post" action="${BASE}/app-groups/${g.id}" class="search u-mwnone">${csrf(s)}
49
+ <label class="sr-only" for="g-${g.id}">New name for ${g.name}</label><input id="g-${g.id}" name="name" value="${g.name}" required maxlength="60">
50
+ <button class="btn btn-sm">Rename</button></form></td>
51
+ <td><form method="post" action="${BASE}/app-groups/${g.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="Delete the group ${g.name}? Its applications stay, without a group.">${icon('trash')}<span class="sr-only">Delete ${g.name}</span></button></form></td></tr>`)}
52
+ </tbody></table></div>`
53
+ : html`<p class="muted">No groups yet.</p>`)}
54
+ ${region('Add a group', html`<form method="post" action="${BASE}/app-groups">${csrf(s)}
55
+ ${input('name', 'Name', '', { required: true, placeholder: 'Finance' })}
56
+ ${input('description', 'Description', '')}
57
+ <div class="buttons"><button class="btn btn-hot">${icon('plus')} Add group</button></div></form>`)}
58
+ </div>
59
+ <div class="u-spacer"></div>
60
+ ${region('Applications', apps.rows.length
61
+ ? html`<form method="post" action="${BASE}/app-groups/assign">${csrf(s)}
62
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Application</th><th>Group</th></tr></thead><tbody>
63
+ ${apps.rows.map((a) => html`<tr><td data-label="Application"><a href="${BASE}/apps/${a.id}">${a.name}</a> <span class="muted">/a/${a.alias}</span></td>
64
+ <td data-label="Group"><label class="sr-only" for="a-${a.id}">Group of ${a.name}</label>
65
+ <select id="a-${a.id}" name="app_${a.id}"><option value="">(no group)</option>${groups.rows.map((g) => html`<option value="${g.id}"${a.group_id === g.id ? raw(' selected') : ''}>${g.name}</option>`)}</select></td></tr>`)}
66
+ </tbody></table></div>
67
+ <div class="buttons"><button class="btn"${groups.rows.length ? '' : raw(' disabled')}>Save groups</button></div></form>`
68
+ : html`<p class="muted">No applications in this workspace.</p>`)}`;
69
+ return send(reply, s, shell(s, 'Application groups', crumbs(), main));
70
+ });
71
+
72
+ app.post(`${BASE}/app-groups`, async (req: Req, reply) => {
73
+ const s = await developer(req, reply);
74
+ if (!s) return;
75
+ const name = String(req.body?.name ?? '').trim();
76
+ try {
77
+ if (!NAME.test(name)) throw new Error('The name is 1 to 60 characters, without spaces at the start or end.');
78
+ await owner.query('insert into meta.app_group (workspace_id, name, description) values ($1, $2, $3)', [currentWorkspace(s), name, String(req.body?.description ?? '').trim().slice(0, 2000) || null]);
79
+ await logActivity({ username: s.username, event: 'app_group', ip: clientIp(req), detail: `added ${name}` });
80
+ flash(s, `Group ${name} added.`);
81
+ } catch (e) {
82
+ flash(s, groupError(e), 'error');
83
+ }
84
+ return back(reply, s, `${BASE}/app-groups`);
85
+ });
86
+
87
+ app.post(`${BASE}/app-groups/:id(^\\d{1,9}$)`, async (req: Req, reply) => {
88
+ const s = await developer(req, reply);
89
+ if (!s) return;
90
+ const name = String(req.body?.name ?? '').trim();
91
+ try {
92
+ if (!NAME.test(name)) throw new Error('The name is 1 to 60 characters, without spaces at the start or end.');
93
+ const r = await owner.query('update meta.app_group set name = $3 where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s), name]);
94
+ if (!r.rowCount) return reply.code(404).send('Not found');
95
+ flash(s, `Group renamed to ${name}.`);
96
+ } catch (e) {
97
+ flash(s, groupError(e), 'error');
98
+ }
99
+ return back(reply, s, `${BASE}/app-groups`);
100
+ });
101
+
102
+ app.post(`${BASE}/app-groups/:id(^\\d{1,9}$)/delete`, async (req: Req, reply) => {
103
+ const s = await developer(req, reply);
104
+ if (!s) return;
105
+ const r = await owner.one<{ name: string }>('delete from meta.app_group where id = $1 and workspace_id = $2 returning name', [req.params.id, currentWorkspace(s)]);
106
+ if (!r) return reply.code(404).send('Not found');
107
+ await logActivity({ username: s.username, event: 'app_group', ip: clientIp(req), detail: `deleted ${r.name}` });
108
+ flash(s, `Group ${r.name} deleted.`);
109
+ return back(reply, s, `${BASE}/app-groups`);
110
+ });
111
+
112
+ // the group of each application of the workspace (only this workspace's groups and applications)
113
+ app.post(`${BASE}/app-groups/assign`, async (req: Req, reply) => {
114
+ const s = await developer(req, reply);
115
+ if (!s) return;
116
+ const ws = currentWorkspace(s);
117
+ const pairs = Object.entries(req.body ?? {}).flatMap(([k, v]) => {
118
+ const m = /^app_(\d{1,9})$/.exec(k);
119
+ return m ? [[Number(m[1]), /^\d{1,9}$/.test(String(v)) ? Number(v) : null] as [number, number | null]] : [];
120
+ }).slice(0, 5000);
121
+ await owner.tx(async (c) => {
122
+ for (const [appId, groupId] of pairs) {
123
+ if (!(await c.query('select 1 from meta.app where id = $1 and meta.app_workspace(id) = $2', [appId, ws])).rowCount) continue;
124
+ if (groupId === null) await c.query('delete from meta.app_group_member where app_id = $1', [appId]);
125
+ else if ((await c.query('select 1 from meta.app_group where id = $1 and workspace_id = $2', [groupId, ws])).rowCount)
126
+ await c.query('insert into meta.app_group_member (app_id, group_id) values ($1, $2) on conflict (app_id) do update set group_id = excluded.group_id', [appId, groupId]);
127
+ }
128
+ });
129
+ flash(s, 'Groups saved.');
130
+ return back(reply, s, `${BASE}/app-groups`);
131
+ });
132
+ }
@@ -129,11 +129,25 @@ function review(s: Session, bp: Blueprint, spec: string, id: number | null, name
129
129
  </form>`)}`;
130
130
  }
131
131
 
132
+ /**
133
+ * (sprint 61) Open a blueprint (e.g. a starter app of the App Gallery): its review when it can be
134
+ * created, else the editor with its problems. Nothing is created before the developer confirms.
135
+ */
136
+ export async function openBlueprint(s: Session, reply: FastifyReply, name: string, spec: string) {
137
+ const { json, error } = parse(spec);
138
+ const { blueprint, problems } = error ? { blueprint: null, problems: [error] } : checkBlueprint(json);
139
+ const taken = blueprint ? await owner.one('select 1 from meta.app where alias = $1', [blueprint.alias]) : null;
140
+ if (taken) problems.push(`An application with the alias ${blueprint!.alias} already exists: change "alias" and "schema" in the blueprint.`);
141
+ if (!blueprint || problems.length)
142
+ return editor(s, reply, { name, spec, message: html`<div class="alert alert-error" role="alert"><p>The blueprint can't be created yet:</p><ul>${problems.map((p) => html`<li>${p}</li>`)}</ul></div>` });
143
+ return page(s, reply, 'Review', review(s, blueprint, spec, null, name));
144
+ }
145
+
132
146
  export async function blueprintRoutes(app: FastifyInstance) {
133
147
  app.get(`${BASE}/blueprints`, async (req: Req, reply) => {
134
148
  const s = await developer(req, reply);
135
149
  if (!s) return;
136
- const list = (await owner.query(`select b.id, b.name, b.created_by, b.updated_at::text as updated, a.id as app_id, a.alias from meta.blueprint b left join meta.app a on a.id = b.app_id order by lower(b.name), b.id`)).rows;
150
+ const list = (await owner.query(`select b.id, b.name, b.created_by, b.updated_at::text as updated, a.id as app_id, a.alias from meta.blueprint b left join meta.app a on a.id = b.app_id where b.workspace_id = $1 order by lower(b.name), b.id`, [currentWorkspace(s)])).rows;
137
151
  return page(s, reply, '', html`<h1>Blueprints</h1>
138
152
  <p class="muted">Describe an application as a blueprint (tables, pages, menu, sample rows), review it and create it. <a class="btn btn-hot" href="${BASE}/blueprints/new">${icon('plus')} New blueprint</a></p>
139
153
  ${list.length
@@ -154,7 +168,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
154
168
  app.get(`${BASE}/blueprints/:id(^\\d+$)`, async (req: Req, reply) => {
155
169
  const s = await developer(req, reply);
156
170
  if (!s) return;
157
- const b = await owner.one('select id, name, spec from meta.blueprint where id = $1', [req.params.id]);
171
+ const b = await owner.one('select id, name, spec from meta.blueprint where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]);
158
172
  if (!b) return reply.code(404).send('Not found');
159
173
  return editor(s, reply, { id: b.id, name: b.name, spec: JSON.stringify(b.spec, null, 2) });
160
174
  });
@@ -178,8 +192,8 @@ export async function blueprintRoutes(app: FastifyInstance) {
178
192
  if (error || !json || typeof json !== 'object' || Array.isArray(json))
179
193
  return editor(s, reply, { ...o, message: html`<div class="alert alert-error" role="alert">${error ?? 'A blueprint is a JSON object.'}</div>` });
180
194
  const id = o.id
181
- ? (await owner.one('update meta.blueprint set name = $2, spec = $3, updated_at = now() where id = $1 returning id', [o.id, o.name, JSON.stringify(json)]))?.id
182
- : (await owner.one('insert into meta.blueprint (name, spec, created_by) values ($1, $2, $3) returning id', [o.name, JSON.stringify(json), s.username])).id;
195
+ ? (await owner.one('update meta.blueprint set name = $2, spec = $3, updated_at = now() where id = $1 and workspace_id = $4 returning id', [o.id, o.name, JSON.stringify(json), currentWorkspace(s)]))?.id
196
+ : (await owner.one('insert into meta.blueprint (name, spec, created_by, workspace_id) values ($1, $2, $3, $4) returning id', [o.name, JSON.stringify(json), s.username, currentWorkspace(s)])).id;
183
197
  if (!id) return reply.code(404).send('Not found');
184
198
  flash(s, `Blueprint ${o.name} saved.`);
185
199
  return back(reply, s, `${BASE}/blueprints/${id}`);
@@ -240,8 +254,8 @@ export async function blueprintRoutes(app: FastifyInstance) {
240
254
  throw new Error(createAppError(e, checked.alias));
241
255
  });
242
256
  const r = await buildBlueprint(c, blueprint, checked.role, { dashboard: (tables, no) => addDashboard(c, checked.alias, tables, no) });
243
- if (o.id) await c.query('update meta.blueprint set app_id = $2, spec = $3, updated_at = now() where id = $1', [o.id, a.id, JSON.stringify(json)]);
244
- else await c.query('insert into meta.blueprint (name, spec, created_by, app_id) values ($1, $2, $3, $4)', [o.name, JSON.stringify(json), s.username, a.id]);
257
+ if (o.id) await c.query('update meta.blueprint set app_id = $2, spec = $3, updated_at = now() where id = $1 and workspace_id = $4', [o.id, a.id, JSON.stringify(json), currentWorkspace(s)]);
258
+ else await c.query('insert into meta.blueprint (name, spec, created_by, app_id, workspace_id) values ($1, $2, $3, $4, $5)', [o.name, JSON.stringify(json), s.username, a.id, currentWorkspace(s)]);
245
259
  return { ...r, existing: a.existingAccount };
246
260
  });
247
261
  await logActivity({ username: s.username, event: 'blueprint', ip: clientIp(req), detail: `created ${blueprint.alias} from blueprint ${o.name}` });
@@ -255,7 +269,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
255
269
  app.post(`${BASE}/blueprints/:id(^\\d+$)/delete`, async (req: Req, reply) => {
256
270
  const s = await developer(req, reply);
257
271
  if (!s) return;
258
- const r = await owner.query('delete from meta.blueprint where id = $1', [req.params.id]);
272
+ const r = await owner.query('delete from meta.blueprint where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]);
259
273
  flash(s, r.rowCount ? 'Blueprint deleted (applications created from it stay).' : 'There is no such blueprint.');
260
274
  return back(reply, s, `${BASE}/blueprints`);
261
275
  });
@@ -1,10 +1,11 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
2
  import pg from 'pg';
3
3
  import { owner, runtime } from '../db.ts';
4
+ import { devDbForApp } from '../workspace-db.ts';
4
5
  import { html, raw, type Raw } from '../html.ts';
5
6
  import { checkSql, type SqlShape } from './advisor.ts';
6
7
  import { BASE, developer, type Req } from './ui.ts';
7
- import { appAllowed } from './workspaces.ts';
8
+ import { appAllowed, workshopDb } from './workspaces.ts';
8
9
 
9
10
  // The builder's code editor (public/code-editor.js) and its SQL completions.
10
11
  //
@@ -215,7 +216,7 @@ export async function codeEditorRoutes(app: FastifyInstance) {
215
216
  if (!sql.trim()) return reply.send({ ok: true, message: 'Nothing to check.' });
216
217
  if (/^\s*(STATIC|LOV):/i.test(sql)) return reply.send({ ok: true, message: 'Not SQL: a static or shared list of values.' });
217
218
  const role = target ? (await owner.one('select db_role from meta.app where id = $1', [target.id]))?.db_role : null;
218
- const c = await owner.pool.connect();
219
+ const c = await (target ? await devDbForApp(target.id) : await workshopDb(s)).pool.connect();
219
220
  try {
220
221
  await c.query('begin');
221
222
  await c.query(`set local statement_timeout = '5s'`);