pgkiln 0.72.0 → 0.74.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 (79) hide show
  1. package/db/migrations/140_item_region_types.sql +27 -0
  2. package/db/migrations/141_workspace_resources.sql +115 -0
  3. package/docs/apex-feature-parity.md +91 -12
  4. package/docs/guide/01-installation.md +1 -0
  5. package/docs/guide/03-builder.md +47 -4
  6. package/docs/guide/04-pages-and-regions.md +41 -1
  7. package/docs/guide/05-items.md +43 -3
  8. package/docs/guide/11-from-apex.md +3 -0
  9. package/docs/guide/12-development.md +25 -3
  10. package/docs/guide/16-files.md +29 -0
  11. package/docs/guide/19-rest-data-sources.md +41 -3
  12. package/examples/hr/hr_75_item_region_types.sql +81 -0
  13. package/examples/hr/hr_76_remote_servers.sql +48 -0
  14. package/package.json +1 -1
  15. package/public/app.css +48 -0
  16. package/public/app.js +377 -1
  17. package/src/ai/service.ts +10 -1
  18. package/src/app.ts +2 -0
  19. package/src/builder/advisor.ts +18 -2
  20. package/src/builder/ai.ts +29 -3
  21. package/src/builder/arrange.ts +7 -0
  22. package/src/builder/components.ts +5 -5
  23. package/src/builder/designer.ts +9 -3
  24. package/src/builder/home.ts +3 -0
  25. package/src/builder/i18n/de.ts +6 -0
  26. package/src/builder/i18n/en.ts +6 -0
  27. package/src/builder/i18n/es.ts +6 -0
  28. package/src/builder/i18n/fr.ts +6 -0
  29. package/src/builder/i18n/it.ts +6 -0
  30. package/src/builder/i18n/ja.ts +6 -0
  31. package/src/builder/i18n/ko.ts +6 -0
  32. package/src/builder/i18n/nl.ts +6 -0
  33. package/src/builder/i18n/pt.ts +6 -0
  34. package/src/builder/i18n/zh-cn.ts +6 -0
  35. package/src/builder/i18n/zh-tw.ts +6 -0
  36. package/src/builder/network-access.ts +33 -0
  37. package/src/builder/region-help.ts +8 -2
  38. package/src/builder/rest-catalogs.ts +22 -14
  39. package/src/builder/routes.ts +5 -1
  40. package/src/builder/static-files.ts +14 -4
  41. package/src/builder/websources.ts +12 -6
  42. package/src/builder/workflows.ts +2 -1
  43. package/src/builder/workspace-resources.ts +528 -0
  44. package/src/i18n/ar.ts +13 -0
  45. package/src/i18n/cs.ts +13 -0
  46. package/src/i18n/da.ts +13 -0
  47. package/src/i18n/de.ts +13 -0
  48. package/src/i18n/el.ts +13 -0
  49. package/src/i18n/es.ts +13 -0
  50. package/src/i18n/fi.ts +13 -0
  51. package/src/i18n/fr.ts +13 -0
  52. package/src/i18n/he.ts +13 -0
  53. package/src/i18n/it.ts +13 -0
  54. package/src/i18n/ja.ts +13 -0
  55. package/src/i18n/ko.ts +13 -0
  56. package/src/i18n/nb.ts +13 -0
  57. package/src/i18n/pl.ts +13 -0
  58. package/src/i18n/pt.ts +13 -0
  59. package/src/i18n/ru.ts +13 -0
  60. package/src/i18n/sv.ts +13 -0
  61. package/src/i18n/tr.ts +13 -0
  62. package/src/i18n/uk.ts +13 -0
  63. package/src/i18n/zh.ts +13 -0
  64. package/src/i18n.ts +26 -0
  65. package/src/metadata.ts +22 -5
  66. package/src/remote-servers.ts +133 -0
  67. package/src/restcatalog.ts +21 -10
  68. package/src/restsync.ts +1 -1
  69. package/src/runtime/context.ts +2 -0
  70. package/src/runtime/items.ts +32 -3
  71. package/src/runtime/page-regions.ts +50 -0
  72. package/src/runtime/region-cache.ts +1 -1
  73. package/src/runtime/regions.ts +11 -0
  74. package/src/runtime/render.ts +3 -17
  75. package/src/runtime/report.ts +55 -3
  76. package/src/runtime/static-files.ts +46 -17
  77. package/src/runtime/template-components.ts +2 -1
  78. package/src/websources.ts +43 -18
  79. package/src/workspace-export.ts +80 -1
package/src/websources.ts CHANGED
@@ -5,6 +5,7 @@ import { runtimeForApp } from './workspace-db.ts';
5
5
  import { decryptSecret, encryptSecret } from './secrets.ts';
6
6
  import { checkUrl, WebError, webRequest, type OutboundScope, type WebResponse } from './webclient.ts';
7
7
  import { scopeForApp } from './network-acl.ts';
8
+ import { resolveSource } from './remote-servers.ts';
8
9
 
9
10
  // Web credentials and REST data sources (meta.web_credential,
10
11
  // meta.rest_source): building the request (parameters, credential), OAuth2
@@ -34,6 +35,8 @@ export interface WebCredential {
34
35
  oauth_username?: string | null;
35
36
  password_enc?: string | null;
36
37
  refresh_token_enc?: string | null;
38
+ /** (141) a credential of the application's workspace (meta.workspace_credential) */
39
+ workspace?: boolean;
37
40
  }
38
41
 
39
42
  export type ParamLocation = 'query' | 'path' | 'header' | 'body';
@@ -56,9 +59,12 @@ export interface RestSource {
56
59
  id: number;
57
60
  app_id: number;
58
61
  name: string;
62
+ /** a full URL; (141) with a server, the path after the server's base URL (resolveSource makes it full) */
59
63
  url: string;
60
64
  method: string;
61
65
  credential: string | null;
66
+ /** (141) a remote server of the application's workspace (static id) */
67
+ server?: string | null;
62
68
  headers: Record<string, string>;
63
69
  params: RestParam[];
64
70
  body: string | null;
@@ -101,9 +107,14 @@ const RESERVED_HEADERS = new Set(['authorization', 'proxy-authorization', 'cooki
101
107
  // ---------------------------------------------------------------- checks (builder, import)
102
108
 
103
109
  /** Problems with a REST data source definition (empty: fine). */
104
- export function sourceProblems(s: { url?: unknown; params?: unknown; columns?: unknown; headers?: unknown; row_selector?: unknown; key_columns?: unknown; operations?: unknown }): string[] {
110
+ export function sourceProblems(s: { url?: unknown; server?: unknown; params?: unknown; columns?: unknown; headers?: unknown; row_selector?: unknown; key_columns?: unknown; operations?: unknown }): string[] {
105
111
  const out: string[] = [];
106
- if (typeof s.url !== 'string' || !/^https?:\/\/[^/\s]+/i.test(s.url)) out.push('The URL must start with http:// or https://.');
112
+ if (s.server != null && s.server !== '') {
113
+ // (141) on a remote server: a path after its base URL (or nothing)
114
+ if (typeof s.server !== 'string' || !/^[A-Z][A-Z0-9_]{0,59}$/.test(s.server)) out.push('The remote server is its static id, e.g. CRM.');
115
+ if (typeof s.url !== 'string' || !/^([/?][^\s]*)?$/.test(s.url)) out.push('With a remote server the URL is the path after its base URL, e.g. /v1/orders/{id} (or empty).');
116
+ else if (s.url.includes('//') || /(^|\/)\.\.?(\/|$|\?)/.test(s.url)) out.push('The path may not contain // or .. segments.');
117
+ } else if (typeof s.url !== 'string' || !/^https?:\/\/[^/\s]+/i.test(s.url)) out.push('The URL must start with http:// or https://.');
107
118
  else if (/[{&]/.test(/^https?:\/\/[^/?#]*/i.exec(s.url)![0])) out.push('The URL\'s host is fixed: parameters can only follow it.');
108
119
  else {
109
120
  try {
@@ -384,21 +395,29 @@ export const withRest = (rowsQuery: string, sql: string | null | undefined) =>
384
395
  export async function loadSource(appId: number, name: string) {
385
396
  // the application's own connection (an isolated workspace's), also from a workflow or job (090)
386
397
  const s = await (await runtimeForApp(appId)).one<RestSource>(
387
- `select id, app_id, name, url, method, credential, headers, params, body, row_selector, columns, cache_seconds, timeout_s, max_rows, key_columns, operations
398
+ `select id, app_id, name, url, method, credential, server, headers, params, body, row_selector, columns, cache_seconds, timeout_s, max_rows, key_columns, operations
388
399
  from meta.rest_source where app_id = $1 and name = $2`,
389
400
  [appId, name.toUpperCase()],
390
401
  );
391
402
  if (!s) throw new WebError(`REST data source ${name} does not exist.`);
392
- return s;
403
+ // (141) on a remote server: the server's URL of this moment, and its credential when the source names none
404
+ return resolveSource(s);
393
405
  }
394
406
 
395
- /** With the (encrypted) secret: the owner connection, never the runtime role. */
407
+ const CREDENTIAL_COLUMNS = 'id, name, type, username, header_name, token_url, scope, valid_for, secret_enc, grant_type, oauth_username, password_enc, refresh_token_enc';
408
+
409
+ /**
410
+ * With the (encrypted) secret: the owner connection, never the runtime role. The application's own
411
+ * credential of that name, else (141) one of its workspace's (never another workspace's).
412
+ */
396
413
  export async function loadCredential(appId: number, name: string) {
397
- const c = await owner.one<WebCredential>(
398
- `select id, app_id, name, type, username, header_name, token_url, scope, valid_for, secret_enc, grant_type, oauth_username, password_enc, refresh_token_enc
399
- from meta.web_credential where app_id = $1 and name = $2`,
414
+ const c = (await owner.one<WebCredential>(
415
+ `select ${CREDENTIAL_COLUMNS}, app_id from meta.web_credential where app_id = $1 and name = $2`,
400
416
  [appId, name.toUpperCase()],
401
- );
417
+ )) ?? (await owner.one<WebCredential>(
418
+ `select ${CREDENTIAL_COLUMNS}, $1::int as app_id, true as workspace from meta.workspace_credential where workspace_id = meta.app_workspace($1) and name = $2`,
419
+ [appId, name.toUpperCase()],
420
+ ));
402
421
  if (!c) throw new WebError(`Web credential ${name} does not exist.`);
403
422
  return c;
404
423
  }
@@ -428,15 +447,18 @@ interface Token {
428
447
  const tokens = new Map<string, Token>();
429
448
  const pendingTokens = new Map<string, Promise<Token>>();
430
449
  const credKey = (c: WebCredential) =>
431
- createHash('sha256').update(JSON.stringify([c.id, c.username, c.token_url, c.scope, c.secret_enc, c.grant_type ?? null, c.oauth_username ?? null, c.password_enc ?? null])).digest('base64url');
450
+ createHash('sha256').update(JSON.stringify([tokenId(c), c.username, c.token_url, c.scope, c.secret_enc, c.grant_type ?? null, c.oauth_username ?? null, c.password_enc ?? null])).digest('base64url');
451
+
452
+ /** The token cache's id of a credential: (141) a workspace credential's starts with w. */
453
+ const tokenId = (c: Pick<WebCredential, 'id' | 'workspace'>) => (c.workspace ? `w${c.id}` : String(c.id));
432
454
 
433
- /** Forget cached tokens (all, or one credential's). */
434
- export function clearTokens(credentialId?: number) {
455
+ /** Forget cached tokens (all, or one credential's: its id, or w<id> for a workspace credential). */
456
+ export function clearTokens(credentialId?: number | string) {
435
457
  for (const k of [...tokens.keys()]) if (credentialId === undefined || k.startsWith(`${credentialId}:`)) tokens.delete(k);
436
458
  }
437
459
 
438
460
  const secretOf = (c: WebCredential) => {
439
- if (!c.secret_enc) throw new WebError(`Web credential ${c.name} has no secret yet: enter it in the builder (Shared Components → Web credentials).`);
461
+ if (!c.secret_enc) throw new WebError(`Web credential ${c.name} has no secret yet: enter it in the builder (${c.workspace ? 'Workspace utilities' : 'Shared Components'} → Web credentials).`);
440
462
  return decryptSecret(c.secret_enc);
441
463
  };
442
464
 
@@ -504,7 +526,7 @@ async function requestToken(c: WebCredential, refresh: string | null, timeoutMs:
504
526
  /** Keep a refresh token (encrypted); written with the owner connection only. */
505
527
  async function storeRefreshToken(c: WebCredential, token: string) {
506
528
  const enc = encryptSecret(token);
507
- await owner.query('update meta.web_credential set refresh_token_enc = $2, token_refreshed_at = now() where id = $1', [c.id, enc]);
529
+ await owner.query(`update meta.${c.workspace ? 'workspace_credential' : 'web_credential'} set refresh_token_enc = $2, token_refreshed_at = now() where id = $1`, [c.id, enc]);
508
530
  c.refresh_token_enc = enc;
509
531
  }
510
532
 
@@ -519,7 +541,7 @@ function storedRefresh(c: WebCredential) {
519
541
  }
520
542
 
521
543
  async function oauthToken(c: WebCredential, timeoutMs: number, scope: OutboundScope | null, fresh = false): Promise<string> {
522
- const key = `${c.id}:${credKey(c)}`;
544
+ const key = `${tokenId(c)}:${credKey(c)}`;
523
545
  const cached = tokens.get(key);
524
546
  if (cached && !fresh && cached.expires > Date.now()) return cached.access;
525
547
  let p = pendingTokens.get(key);
@@ -609,7 +631,7 @@ export async function call(o: CallOptions): Promise<WebResponse> {
609
631
  };
610
632
  const res = await send(false);
611
633
  if (res.status === 401 && o.credential?.type === 'oauth2') {
612
- clearTokens(o.credential.id);
634
+ clearTokens(tokenId(o.credential));
613
635
  return send(true);
614
636
  }
615
637
  return res;
@@ -759,7 +781,8 @@ export function buildOperation(s: RestSource, name: OperationName, row: Record<s
759
781
  * 200–299 fails. Returns the response's JSON (null when empty) and, for
760
782
  * insert and fetch, the row in it (by the operation's row selector).
761
783
  */
762
- export async function callOperation(s: RestSource, name: OperationName, row: Record<string, unknown>, params: Record<string, string> = {}) {
784
+ export async function callOperation(source: RestSource, name: OperationName, row: Record<string, unknown>, params: Record<string, string> = {}) {
785
+ const s = await resolveSource(source);
763
786
  const req = buildOperation(s, name, row, params);
764
787
  const credential = s.credential ? await loadCredential(s.app_id, s.credential) : null;
765
788
  const res = await call({ ...req, appId: s.app_id, what: `REST data source ${s.name}`, credential, timeoutMs: s.timeout_s * 1000 });
@@ -790,7 +813,9 @@ export function clearResponseCache(sourceId?: number) {
790
813
  }
791
814
 
792
815
  /** The parsed JSON response of a source (from the cache when it is fresh enough). */
793
- export async function fetchSource(s: RestSource, values: Record<string, string | null | undefined>) {
816
+ export async function fetchSource(source: RestSource, values: Record<string, string | null | undefined>) {
817
+ // (141) on a remote server: its URL now (a source from loadSource is resolved already)
818
+ const s = await resolveSource(source);
794
819
  const req = buildRequest(s, values);
795
820
  // (sprint 118) the workspace's network access list, before the cache: a narrowed list applies at once
796
821
  const scope = await scopeForApp(s.app_id, `REST data source ${s.name}`);
@@ -2,6 +2,9 @@ import type { Client } from './db.ts';
2
2
  import { owner } from './db.ts';
3
3
  import { checkDataset, datasetOf, storeDataset } from './datasets.ts';
4
4
  import { describeTeam, exportTeam, importTeam } from './team.ts';
5
+ import { serverProblems } from './remote-servers.ts';
6
+ import { STATIC_MAX, staticType } from './runtime/static-files.ts';
7
+ import { credentialProblems, GRANT_TYPES } from './websources.ts';
5
8
  import { catalogDocument, fromCatalogDocument, insertCatalog, loadCatalog, replaceCatalog, wholeCatalogProblems } from './restcatalog.ts';
6
9
 
7
10
  // Workspace utilities → Export / import (APEX: Workspace Utilities → Export,
@@ -21,6 +24,10 @@ import { catalogDocument, fromCatalogDocument, insertCatalog, loadCatalog, repla
21
24
  // datasets (sprint 110) Data Reporter datasets (pgkiln-dataset/1 definitions)
22
25
  // team (sprint 113) Team Development: label groups, labels, milestones, templates and issues
23
26
  // (with comments and history; applications by alias), an object (src/team.ts)
27
+ // workspace_files (sprint 141) static workspace files (content in base64)
28
+ // remote_servers (sprint 141) remote servers; an existing server keeps its own base URL on import
29
+ // (the URL is per installation: development, test, production)
30
+ // web_credentials (sprint 141) the workspace's web credentials, never their secrets (entered again after an import)
24
31
  //
25
32
  // Not exported: the workspace's name (the importer's workspace keeps its own),
26
33
  // isolation and its database roles (installation-specific), script runs and
@@ -29,7 +36,8 @@ import { catalogDocument, fromCatalogDocument, insertCatalog, loadCatalog, repla
29
36
  // installation has a developer of that name.
30
37
 
31
38
  export const WORKSPACE_FORMAT = 'pgkiln-workspace/1';
32
- export const PARTS = ['settings', 'developers', 'app_groups', 'extension_links', 'rest_catalogs', 'sql_scripts', 'blueprints', 'data_generators', 'datasets', 'team'] as const;
39
+ export const PARTS = ['settings', 'developers', 'app_groups', 'extension_links', 'rest_catalogs', 'sql_scripts', 'blueprints', 'data_generators', 'datasets', 'team',
40
+ 'workspace_files', 'remote_servers', 'web_credentials'] as const;
33
41
  export type Part = (typeof PARTS)[number];
34
42
  export const PART_LABELS: Record<Part, string> = {
35
43
  settings: 'Settings: description, message, banner and preferences',
@@ -42,6 +50,9 @@ export const PART_LABELS: Record<Part, string> = {
42
50
  data_generators: 'Data generators',
43
51
  datasets: 'Data Reporter datasets',
44
52
  team: 'Team Development: labels, milestones, templates and issues',
53
+ workspace_files: 'Static workspace files',
54
+ remote_servers: 'Remote servers (an existing server keeps its base URL)',
55
+ web_credentials: 'Web credentials (without their secrets)',
45
56
  };
46
57
 
47
58
  const MAX_ITEMS = 5000;
@@ -82,6 +93,14 @@ export async function exportWorkspace(workspaceId: number, parts: readonly Part[
82
93
  if (want('datasets'))
83
94
  doc.datasets = (await owner.query('select * from meta.dataset where workspace_id = $1 order by name', [workspaceId])).rows.map(datasetOf);
84
95
  if (want('team')) doc.team = await exportTeam(workspaceId);
96
+ if (want('workspace_files'))
97
+ doc.workspace_files = (await owner.query(`select name, mime, translate(encode(content, 'base64'), E'\n', '') as content from meta.workspace_file where workspace_id = $1 order by name`, [workspaceId])).rows;
98
+ if (want('remote_servers'))
99
+ doc.remote_servers = (await owner.query('select static_id, name, description, base_url, env_var, credential from meta.remote_server where workspace_id = $1 order by static_id', [workspaceId])).rows;
100
+ if (want('web_credentials'))
101
+ // secrets never leave the installation
102
+ doc.web_credentials = (await owner.query(
103
+ 'select name, description, type, username, header_name, token_url, scope, valid_for, grant_type, oauth_username from meta.workspace_credential where workspace_id = $1 order by name', [workspaceId])).rows;
85
104
  return doc;
86
105
  }
87
106
 
@@ -271,5 +290,65 @@ export async function importWorkspace(c: Client, workspaceId: number, doc: unkno
271
290
  const t = await importTeam(c, workspaceId, d.team, opts);
272
291
  tally('Team Development', t.added, t.replaced, t.skipped, t.issues ? `; ${t.issues} issue(s) with their comments` : '');
273
292
  }
293
+
294
+ if (want('workspace_files')) {
295
+ // (sprint 141) the same names, types and sizes as an upload
296
+ const n = count();
297
+ for (const x of d.workspace_files as Json[]) {
298
+ const name = isObj(x) && typeof x.name === 'string' ? x.name : '';
299
+ const type = staticType(name);
300
+ if (!type) throw new Error(`Static workspace file ${JSON.stringify(name).slice(0, 110)}: not an allowed name or type.`);
301
+ const content = typeof x.content === 'string' ? Buffer.from(x.content, 'base64') : Buffer.alloc(0);
302
+ if (content.length > STATIC_MAX) throw new Error(`Static workspace file ${name}: larger than ${STATIC_MAX / 1024 / 1024} MB.`);
303
+ const existing = (await c.query<{ id: number }>('select id from meta.workspace_file where workspace_id = $1 and name = $2', [workspaceId, name])).rows[0];
304
+ n[await upsert(existing,
305
+ () => c.query('insert into meta.workspace_file (workspace_id, name, mime, content, updated_by) values ($1, $2, $3, $4, $5)', [workspaceId, name, type, content, opts.by]),
306
+ (id) => c.query('update meta.workspace_file set mime = $2, content = $3, updated_by = $4, updated_at = now() where id = $1', [id, type, content, opts.by]))]++;
307
+ }
308
+ tally('Static workspace files', n.added, n.replaced, n.skipped);
309
+ }
310
+
311
+ if (want('remote_servers')) {
312
+ // (sprint 141) the base URL is this installation's: an existing server keeps its own, a new one takes the file's
313
+ const n = count();
314
+ for (const x of d.remote_servers as Json[]) {
315
+ if (!isObj(x)) continue;
316
+ const v = { static_id: x.static_id, name: x.name, description: text(x.description, 2000), base_url: text(x.base_url, 2000), env_var: text(x.env_var, 80), credential: text(x.credential, 60) };
317
+ const problems = serverProblems(v);
318
+ if (problems.length) throw new Error(`Remote server ${typeof x.static_id === 'string' ? x.static_id : '?'}: ${problems.join(' ')}`);
319
+ const existing = (await c.query<{ id: number }>('select id from meta.remote_server where workspace_id = $1 and static_id = $2', [workspaceId, v.static_id])).rows[0];
320
+ n[await upsert(existing,
321
+ () => c.query('insert into meta.remote_server (workspace_id, static_id, name, description, base_url, env_var, credential, updated_by) values ($1, $2, $3, $4, $5, $6, $7, $8)',
322
+ [workspaceId, v.static_id, v.name, v.description, v.base_url, v.env_var, v.credential, opts.by]),
323
+ (id) => c.query(`update meta.remote_server set name = $2, description = $3, env_var = $4, credential = $5, base_url = coalesce(base_url, $6),
324
+ updated_by = $7, updated_at = now() where id = $1`, [id, v.name, v.description, v.env_var, v.credential, v.base_url, opts.by]))]++;
325
+ }
326
+ tally('Remote servers', n.added, n.replaced, n.skipped, n.replaced ? '; existing servers kept their base URL' : '');
327
+ }
328
+
329
+ if (want('web_credentials')) {
330
+ // (sprint 141) without secrets: an existing credential keeps its own, a new one gets them entered in the builder
331
+ const n = count();
332
+ for (const x of d.web_credentials as Json[]) {
333
+ if (!isObj(x)) continue;
334
+ const v = {
335
+ name: String(x.name ?? ''), description: text(x.description, 2000),
336
+ type: ['basic', 'header', 'bearer', 'oauth2', 'aws_sigv4'].includes(x.type) ? x.type : 'basic',
337
+ username: text(x.username, 500), header_name: text(x.header_name, 64), token_url: text(x.token_url, 2000), scope: text(x.scope, 500),
338
+ valid_for: Array.isArray(x.valid_for) ? x.valid_for.filter((u: unknown) => typeof u === 'string') : [],
339
+ grant_type: GRANT_TYPES.includes(x.grant_type) ? x.grant_type : 'client_credentials', oauth_username: text(x.oauth_username, 500),
340
+ };
341
+ const problems = [...(/^[A-Z][A-Z0-9_]{0,59}$/.test(v.name) ? [] : ['the name is upper case letters, digits and _.']), ...credentialProblems(v)];
342
+ if (problems.length) throw new Error(`Web credential ${v.name || '?'}: ${problems.join(' ')}`);
343
+ const vals = [v.description, v.type, v.username, v.header_name, v.token_url, v.scope, v.valid_for, v.grant_type, v.oauth_username];
344
+ const existing = (await c.query<{ id: number }>('select id from meta.workspace_credential where workspace_id = $1 and name = $2', [workspaceId, v.name])).rows[0];
345
+ n[await upsert(existing,
346
+ () => c.query(`insert into meta.workspace_credential (workspace_id, name, description, type, username, header_name, token_url, scope, valid_for, grant_type, oauth_username, updated_by)
347
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, [workspaceId, v.name, ...vals, opts.by]),
348
+ (id) => c.query(`update meta.workspace_credential set description = $2, type = $3, username = $4, header_name = $5, token_url = $6, scope = $7, valid_for = $8,
349
+ grant_type = $9, oauth_username = $10, updated_by = $11, updated_at = now() where id = $1`, [id, ...vals, opts.by]))]++;
350
+ }
351
+ tally('Web credentials', n.added, n.replaced, n.skipped, n.added ? '; enter their secrets under Workspace utilities → Web credentials' : '');
352
+ }
274
353
  return r;
275
354
  }