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.
- package/SECURITY.md +12 -9
- package/db/migrations/090_workspace_isolation.sql +512 -0
- package/db/migrations/091_workspace_service.sql +20 -0
- package/db/migrations/092_app_groups.sql +23 -0
- package/docs/apex-feature-parity.md +16 -11
- package/docs/guide/03-builder.md +125 -11
- package/docs/guide/12-development.md +12 -2
- package/examples/gallery/README.md +13 -0
- package/examples/gallery/gallery.json +20 -0
- package/examples/gallery/starters/asset-inventory.json +30 -0
- package/examples/gallery/starters/contacts.json +41 -0
- package/examples/gallery/starters/event-registrations.json +29 -0
- package/examples/gallery/starters/expense-claims.json +32 -0
- package/examples/gallery/starters/team-tasks.json +35 -0
- package/examples/hr/hr_40_sample_data.sql +1 -1
- package/package.json +1 -1
- package/public/builder.css +91 -0
- package/src/app.ts +24 -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/app-groups.ts +132 -0
- package/src/builder/blueprints.ts +21 -7
- package/src/builder/code-editor.ts +3 -2
- package/src/builder/cross-app.ts +106 -0
- 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/gallery.ts +154 -0
- package/src/builder/home.ts +33 -10
- 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 +78 -26
- package/src/builder/sampledata.ts +22 -18
- package/src/builder/scripts.ts +31 -16
- package/src/builder/sql.ts +119 -15
- package/src/builder/ui.ts +30 -7
- package/src/builder/unload.ts +3 -2
- package/src/builder/workspaces.ts +291 -22
- package/src/cli/replace.ts +2 -0
- 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/calendar.ts +2 -1
- package/src/runtime/routes.ts +4 -1
- package/src/sampledata.ts +3 -3
- package/src/session.ts +18 -4
- package/src/websources.ts +3 -1
- package/src/workflow.ts +65 -9
- package/src/workspace-db.ts +166 -0
package/public/builder.css
CHANGED
|
@@ -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);
|
package/src/automations.ts
CHANGED
|
@@ -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
|
package/src/builder/advisor.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
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'`);
|