@kazurolabs/psql-explorer 0.1.0 → 0.3.1

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 (77) hide show
  1. package/README.md +229 -18
  2. package/RELEASES.md +95 -0
  3. package/dist/cli.js +57 -13
  4. package/dist/commands/configgen.js +25 -25
  5. package/dist/commands/explore.js +86 -4
  6. package/dist/config/load.js +11 -73
  7. package/dist/config/resolve-environment.js +10 -62
  8. package/dist/config/resolvers/index.js +1 -1
  9. package/dist/config/resolvers/prompt.js +2 -2
  10. package/dist/config/resolvers/vault.js +1 -83
  11. package/dist/db/store.js +57 -0
  12. package/dist/environments.js +5 -2
  13. package/dist/explorer/alter.js +493 -0
  14. package/dist/explorer/catalog.js +31 -0
  15. package/dist/explorer/connect-entry.js +29 -18
  16. package/dist/explorer/cypher-parse.js +478 -0
  17. package/dist/explorer/cypher.js +963 -0
  18. package/dist/explorer/ddl.js +502 -0
  19. package/dist/explorer/dependents.js +222 -0
  20. package/dist/explorer/design-load.js +254 -0
  21. package/dist/explorer/design.js +45 -0
  22. package/dist/explorer/edit.js +108 -0
  23. package/dist/explorer/explain.js +44 -3
  24. package/dist/explorer/filter.js +121 -0
  25. package/dist/explorer/graph.js +679 -0
  26. package/dist/explorer/gucs.js +31 -0
  27. package/dist/explorer/profile.js +102 -0
  28. package/dist/explorer/references.js +95 -0
  29. package/dist/explorer/rows.js +100 -13
  30. package/dist/explorer/schema-diff.js +360 -0
  31. package/dist/explorer/search.js +309 -0
  32. package/dist/explorer/security.js +152 -0
  33. package/dist/explorer/seed.js +1376 -0
  34. package/dist/explorer/session.js +25 -1
  35. package/dist/explorer/stats.js +135 -0
  36. package/dist/explorer/timeout.js +13 -0
  37. package/dist/screens/environments.js +6 -3
  38. package/dist/screens/explorer.js +0 -3
  39. package/dist/screens/splash.js +4 -2
  40. package/dist/web/server.js +655 -22
  41. package/package.json +4 -1
  42. package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
  43. package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
  44. package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
  45. package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
  46. package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
  47. package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
  48. package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
  49. package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
  50. package/web/dist/assets/button-BiFx4Lyw.js +1 -0
  51. package/web/dist/assets/code-xml-CPEjNFOw.js +1 -0
  52. package/web/dist/assets/compare-view-COQ3yE76.js +2 -0
  53. package/web/dist/assets/copy-C2N9tUYJ.js +1 -0
  54. package/web/dist/assets/cypher-view-BzyL4mNU.js +13 -0
  55. package/web/dist/assets/design-view-BPm7dSTY.js +9 -0
  56. package/web/dist/assets/erd-view-DqGDws2_.js +1 -0
  57. package/web/dist/assets/format-sql-CoddYUOF.js +5 -0
  58. package/web/dist/assets/graph-force-ChCGXQUt.js +1 -0
  59. package/web/dist/assets/graph-link-C8P9s_0W.js +1 -0
  60. package/web/dist/assets/graph-view-BpoUrUCP.js +3 -0
  61. package/web/dist/assets/index-CpHnjRLv.js +10 -0
  62. package/web/dist/assets/index-OzJqnJWG.css +2 -0
  63. package/web/dist/assets/key-round-JiGCgCWS.js +1 -0
  64. package/web/dist/assets/loader-circle-BxDJOoJO.js +1 -0
  65. package/web/dist/assets/query-builder-view-CtRexMEX.js +4 -0
  66. package/web/dist/assets/rotate-ccw-CwdA-J3t.js +1 -0
  67. package/web/dist/assets/route-7eu-iAqD.js +15 -0
  68. package/web/dist/assets/seed-view-BIkBTiU6.js +7 -0
  69. package/web/dist/assets/shield-alert-keJIAND5.js +1 -0
  70. package/web/dist/assets/sliders-horizontal-DKtL_ql7.js +1 -0
  71. package/web/dist/assets/sql-tabs-DUSXdYCr.js +2 -0
  72. package/web/dist/assets/table-Drc_n1gF.js +1 -0
  73. package/web/dist/assets/triangle-alert-C5nF69rW.js +1 -0
  74. package/web/dist/assets/zone-CHDOrDBF.js +5 -0
  75. package/web/dist/index.html +9 -2
  76. package/web/dist/assets/index-BMLFCtFY.js +0 -17
  77. package/web/dist/assets/index-CMXJRL9O.css +0 -2
@@ -5,6 +5,7 @@ import { addressOf, findEnvironment, listEnvironments, } from '../environments.j
5
5
  import { describeTable, listSchemas, listTables, parseTableRef, serverInfo, tableLabel, } from '../explorer/catalog.js';
6
6
  import { commandSummary, formatBytes, formatDuration, formatEstimate, formatResult, outputFormats, } from '../explorer/format.js';
7
7
  import { browseRows, DEFAULT_PAGE_SIZE } from '../explorer/rows.js';
8
+ import { compareSchemas } from '../explorer/schema-diff.js';
8
9
  import { DEFAULT_MAX_ROWS, describeQueryError, } from '../explorer/session.js';
9
10
  import { connectEntry } from '../explorer/connect-entry.js';
10
11
  export const commandNames = [
@@ -16,6 +17,7 @@ export const commandNames = [
16
17
  'query',
17
18
  'info',
18
19
  'history',
20
+ 'diff',
19
21
  ];
20
22
  export const isCommand = (name) => commandNames.includes(name);
21
23
  /** Thrown for a wrong command line: exit code 2. */
@@ -41,8 +43,7 @@ function formatOf(flags) {
41
43
  }
42
44
  return format;
43
45
  }
44
- function environmentFor(options) {
45
- const { env } = options.flags;
46
+ function environmentFor(options, env = options.flags.env) {
46
47
  if (!env)
47
48
  throw new UsageError(`${options.command} needs --env <name>`);
48
49
  const entry = findEnvironment(options.services, env);
@@ -66,8 +67,6 @@ async function withSession(options, work) {
66
67
  }
67
68
  finally {
68
69
  await session.close();
69
- // Credentials issued for this command end with it.
70
- await options.services.groups?.release(entry.name);
71
70
  }
72
71
  }
73
72
  function envs(options) {
@@ -253,6 +252,87 @@ async function info(options, session, entry) {
253
252
  ]), format));
254
253
  return 0;
255
254
  }
255
+ const indent = (text, prefix) => text
256
+ .split('\n')
257
+ .map((line) => `${prefix}${line}`)
258
+ .join('\n');
259
+ /** One object that differs, as text: its parts with each side's value, or a line by line diff. */
260
+ function describeChange(change, a, b) {
261
+ const name = change.kind === 'schema' || change.kind === 'extension'
262
+ ? change.name
263
+ : `${change.schema}.${change.name}`;
264
+ const status = change.status === 'only-a'
265
+ ? `only in ${a}`
266
+ : change.status === 'only-b'
267
+ ? `only in ${b}`
268
+ : 'differs';
269
+ const out = [`${change.kind} ${name}: ${status}`];
270
+ const width = Math.max(a.length, b.length) + 1;
271
+ for (const part of change.parts) {
272
+ const value = part.a ?? part.b ?? '';
273
+ const label = change.status === 'changed' && (part.a === null || part.b === null)
274
+ ? `${part.part}, only in ${part.a === null ? b : a}`
275
+ : part.part;
276
+ if (change.status !== 'changed' || part.a === null || part.b === null) {
277
+ out.push(value.includes('\n') ? ` ${label}:\n${indent(value, ' ')}` : ` ${label}: ${value}`);
278
+ }
279
+ else if (part.lines) {
280
+ out.push(` ${part.part} (- ${a}, + ${b})`);
281
+ for (const line of part.lines) {
282
+ out.push(` ${line.side === 'a' ? '-' : line.side === 'b' ? '+' : ' '} ${line.text}`);
283
+ }
284
+ }
285
+ else {
286
+ out.push(` ${part.part}`, ` ${`${a}:`.padEnd(width)} ${part.a}`, ` ${`${b}:`.padEnd(width)} ${part.b}`);
287
+ }
288
+ }
289
+ return out.join('\n');
290
+ }
291
+ async function diff(options) {
292
+ const format = formatOf(options.flags);
293
+ const entry = environmentFor(options);
294
+ if (!options.flags.with)
295
+ throw new UsageError('diff needs --with <name>, the environment to compare with');
296
+ const other = environmentFor(options, options.flags.with);
297
+ const { schema, withSchema } = options.flags;
298
+ const session = await connect(options, entry);
299
+ let found;
300
+ try {
301
+ const otherSession = await connect(options, other);
302
+ try {
303
+ found = await compareSchemas(session, otherSession, { schema, withSchema });
304
+ }
305
+ finally {
306
+ await otherSession.close();
307
+ }
308
+ }
309
+ finally {
310
+ await session.close();
311
+ }
312
+ if (format === 'json') {
313
+ json(options, { a: entry.name, b: other.name, ...found });
314
+ return 0;
315
+ }
316
+ if (format === 'csv') {
317
+ options.out(formatResult(result(['schema', 'kind', 'name', 'status', 'part', entry.name, other.name], found.changes.flatMap((change) => (change.parts.length > 0 ? change.parts : [{ part: null, a: null, b: null }]).map((part) => [
318
+ change.schema,
319
+ change.kind,
320
+ change.name,
321
+ change.status,
322
+ part.part,
323
+ part.a,
324
+ part.b,
325
+ ]))), 'csv'));
326
+ return 0;
327
+ }
328
+ for (const change of found.changes)
329
+ options.out(`${describeChange(change, entry.name, other.name)}\n\n`);
330
+ const differ = found.changes.length;
331
+ options.out(differ === 0
332
+ ? `${entry.name} and ${other.name} are the same (${found.same} objects).\n`
333
+ : `${differ} ${differ === 1 ? 'object differs' : 'objects differ'}, ${found.same} the same.\n`);
334
+ return 0;
335
+ }
256
336
  function history(options) {
257
337
  const format = formatOf(options.flags);
258
338
  const entry = environmentFor(options);
@@ -296,6 +376,8 @@ export async function runCommand(options) {
296
376
  }
297
377
  case 'info':
298
378
  return await withSession(options, (session, entry) => info(options, session, entry));
379
+ case 'diff':
380
+ return await diff(options);
299
381
  }
300
382
  }
301
383
  catch (error) {
@@ -4,7 +4,6 @@ import { createSource, SourceError } from './source.js';
4
4
  export const environmentNamePattern = /^[a-z0-9][a-z0-9-_]*$/;
5
5
  const envVariablePattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
6
6
  const vaultPattern = /^[^#\s]+#[^#\s]+$/;
7
- const vaultRolePattern = /^[^#\s/][^#\s]*$/;
8
7
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
9
8
  export const environmentsVariable = 'PSQL_EXPLORER_ENVIRONMENTS';
10
9
  /**
@@ -121,38 +120,28 @@ class Checker {
121
120
  'schema',
122
121
  'username',
123
122
  'password',
124
- 'credentials',
125
123
  'auth',
126
124
  'tls',
127
125
  'readOnly',
128
126
  'confirm',
127
+ 'seeder',
128
+ 'designer',
129
129
  ]);
130
130
  const tier = this.choice(raw.tier, [...path, 'tier'], tiers, undefined);
131
131
  const auth = this.choice(raw.auth, [...path, 'auth'], authModes, 'password');
132
132
  const host = this.value(raw.host, [...path, 'host'], { required: true });
133
133
  const port = this.value(raw.port, [...path, 'port'], { required: true, number: true });
134
134
  const database = this.value(raw.database, [...path, 'database'], { required: true });
135
- const credentials = this.credentials(raw.credentials, [...path, 'credentials']);
136
- const hasCredentials = raw.credentials !== undefined;
137
135
  const schema = this.value(raw.schema, [...path, 'schema']);
138
- const username = this.value(raw.username, [...path, 'username'], { required: !hasCredentials });
136
+ const username = this.value(raw.username, [...path, 'username'], { required: true });
139
137
  const password = this.value(raw.password, [...path, 'password']);
140
138
  const isProtected = raw.protected === undefined ? undefined : this.flag(raw.protected, [...path, 'protected']);
141
139
  const readOnly = raw.readOnly === undefined ? undefined : this.flag(raw.readOnly, [...path, 'readOnly']);
142
140
  const confirm = raw.confirm === undefined ? undefined : this.flag(raw.confirm, [...path, 'confirm']);
141
+ const seeder = raw.seeder === undefined ? undefined : this.flag(raw.seeder, [...path, 'seeder']);
142
+ const designer = raw.designer === undefined ? undefined : this.flag(raw.designer, [...path, 'designer']);
143
143
  const tls = this.tls(raw.tls, [...path, 'tls']);
144
- if (hasCredentials) {
145
- // Username and password come from Vault: setting them too would be two answers.
146
- for (const field of ['username', 'password']) {
147
- if (raw[field] !== undefined) {
148
- this.add([...path, field], 'must not be set when credentials is given: Vault supplies it', true);
149
- }
150
- }
151
- if (auth === 'none') {
152
- this.add([...path, 'auth'], 'must be password when credentials is given', true);
153
- }
154
- }
155
- else if (auth === 'password' && raw.password === undefined) {
144
+ if (auth === 'password' && raw.password === undefined) {
156
145
  this.add([...path, 'password'], 'is required when auth is password');
157
146
  }
158
147
  if (auth === 'none' && raw.password !== undefined) {
@@ -168,12 +157,13 @@ class Checker {
168
157
  protected: guarded,
169
158
  readOnly: readOnly ?? guarded,
170
159
  confirm: confirm ?? guarded,
160
+ seeder: seeder ?? (tier === 'development' || tier === 'local'),
161
+ designer: designer ?? (tier === 'development' || tier === 'local'),
171
162
  host: host,
172
163
  port: port,
173
164
  database: database,
174
165
  ...(schema === undefined ? {} : { schema }),
175
- ...(username === undefined ? {} : { username }),
176
- ...(credentials === undefined ? {} : { credentials }),
166
+ username: username,
177
167
  auth: auth,
178
168
  ...(password === undefined ? {} : { password }),
179
169
  tls: tls,
@@ -185,9 +175,8 @@ class Checker {
185
175
  if (!isRecord(raw))
186
176
  return this.fail(path, 'must be a mapping');
187
177
  const before = this.problems.length;
188
- this.unknownKeys(raw, path, ['mode', 'client', 'ca', 'cert', 'key', 'keyPassphrase']);
178
+ this.unknownKeys(raw, path, ['mode', 'ca', 'cert', 'key', 'keyPassphrase']);
189
179
  const mode = this.choice(raw.mode, [...path, 'mode'], tlsModes, 'disable');
190
- const client = this.client(raw.client, [...path, 'client']);
191
180
  const ca = this.value(raw.ca, [...path, 'ca']);
192
181
  const cert = this.value(raw.cert, [...path, 'cert']);
193
182
  const key = this.value(raw.key, [...path, 'key']);
@@ -198,20 +187,7 @@ class Checker {
198
187
  if (raw.key !== undefined && raw.cert === undefined) {
199
188
  this.add([...path, 'key'], 'cert and key must be given together', true);
200
189
  }
201
- if (raw.client !== undefined) {
202
- // The certificate, its key and (unless given) the CA all come from Vault.
203
- for (const field of ['cert', 'key', 'keyPassphrase']) {
204
- if (raw[field] !== undefined) {
205
- this.add([...path, field], 'must not be set when client is given: Vault supplies the certificate and key', true);
206
- }
207
- }
208
- if (mode === 'disable') {
209
- this.add([...path, 'client'], 'has no effect when tls mode is disable', true);
210
- }
211
- }
212
- if ((mode === 'verify-ca' || mode === 'verify-full') &&
213
- raw.ca === undefined &&
214
- raw.client === undefined) {
190
+ if ((mode === 'verify-ca' || mode === 'verify-full') && raw.ca === undefined) {
215
191
  this.add([...path, 'ca'], `is required when tls mode is ${mode}`);
216
192
  }
217
193
  if (this.problems.length > before)
@@ -219,47 +195,9 @@ class Checker {
219
195
  const optional = { ca, cert, key, keyPassphrase };
220
196
  return {
221
197
  mode: mode,
222
- ...(client === undefined ? {} : { client }),
223
198
  ...Object.fromEntries(Object.entries(optional).filter(([, value]) => value !== undefined)),
224
199
  };
225
200
  }
226
- credentials(raw, path) {
227
- if (raw === undefined)
228
- return undefined;
229
- if (!isRecord(raw)) {
230
- return this.fail(path, 'must be a mapping: source: vault-database and value: <role path>');
231
- }
232
- this.unknownKeys(raw, path, ['source', 'value']);
233
- this.sourceIs(raw, path, 'vault-database');
234
- const vaultDatabase = this.vaultPath(raw.value, [...path, 'value'], 'database/creds/<role>');
235
- return vaultDatabase === undefined ? undefined : { vaultDatabase };
236
- }
237
- client(raw, path) {
238
- if (raw === undefined)
239
- return undefined;
240
- if (!isRecord(raw)) {
241
- return this.fail(path, 'must be a mapping: source: vault-pki, value: <role path> and commonName');
242
- }
243
- this.unknownKeys(raw, path, ['source', 'value', 'commonName']);
244
- this.sourceIs(raw, path, 'vault-pki');
245
- const vaultPki = this.vaultPath(raw.value, [...path, 'value'], 'pki/issue/<role>');
246
- const commonName = this.text(raw.commonName, [...path, 'commonName'], { required: true });
247
- return vaultPki === undefined || commonName === undefined ? undefined : { vaultPki, commonName };
248
- }
249
- // A group of fields filled by one Vault lease names its kind as the source.
250
- sourceIs(raw, path, expected) {
251
- if (raw.source !== expected) {
252
- this.add([...path, 'source'], `must be ${expected}`, raw.source !== undefined);
253
- }
254
- }
255
- // An API path in Vault, e.g. "pki/issue/migrator".
256
- vaultPath(raw, path, example) {
257
- if (raw === undefined)
258
- return this.fail(path, 'is required');
259
- return typeof raw === 'string' && vaultRolePattern.test(raw)
260
- ? raw
261
- : this.fail(path, `must be a Vault API path such as ${example}`);
262
- }
263
201
  // A literal text setting.
264
202
  text(raw, path, options) {
265
203
  if (raw === undefined) {
@@ -17,20 +17,6 @@ export async function resolveEnvironment(env, ctx) {
17
17
  interactive: ctx.interactive,
18
18
  signal: ctx.signal,
19
19
  }, registry);
20
- const groupContext = (field) => {
21
- if (!ctx.groups)
22
- throw fail(field, 'no resolver for Vault credentials or certificates');
23
- return {
24
- groups: ctx.groups,
25
- ctx: {
26
- envName: env.name,
27
- field,
28
- configDir: env.dir,
29
- interactive: ctx.interactive,
30
- signal: ctx.signal,
31
- },
32
- };
33
- };
34
20
  // Text from a file or a prompt often carries a stray newline or space.
35
21
  const checked = async (field, value, rule) => {
36
22
  const text = (await resolve(field, value)).trim();
@@ -59,48 +45,20 @@ export async function resolveEnvironment(env, ctx) {
59
45
  const schema = env.schema === undefined
60
46
  ? defaultSchemaName
61
47
  : await checked('schema', env.schema, (v) => identifierError('Schema', v));
62
- let username;
48
+ const username = await checked('username', env.username, (v) => identifierError('Username', v));
63
49
  let password;
64
- if (env.credentials) {
65
- // One lease supplies both, so they are looked up together.
66
- const { groups, ctx: credentialsCtx } = groupContext('credentials');
67
- const issued = await groups.databaseCredentials(env.credentials.vaultDatabase, credentialsCtx);
68
- username = issued.username.trim();
69
- const userMessage = identifierError('Username', username);
70
- if (userMessage)
71
- throw fail('credentials', `Vault issued an invalid username: ${userMessage}`);
72
- const passwordMessage = passwordError(issued.password);
73
- if (passwordMessage)
74
- throw fail('credentials', `Vault issued an invalid password`);
75
- password = issued.password;
76
- }
77
- else {
78
- username = await checked('username', env.username, (v) => identifierError('Username', v));
79
- if (env.auth === 'password' && env.password !== undefined) {
80
- const text = await resolve('password', env.password);
81
- const message = passwordError(text);
82
- if (message)
83
- throw fail('password', message);
84
- password = text;
85
- }
50
+ if (env.auth === 'password' && env.password !== undefined) {
51
+ const text = await resolve('password', env.password);
52
+ const message = passwordError(text);
53
+ if (message)
54
+ throw fail('password', message);
55
+ password = text;
86
56
  }
87
57
  const tls = { mode: env.tls.mode };
88
58
  // With TLS off these are never used, so they are not looked up (or prompted for).
89
59
  if (env.tls.mode !== 'disable') {
90
60
  if (env.tls.ca !== undefined)
91
61
  tls.ca = await pem('tls.ca', env.tls.ca);
92
- if (env.tls.client) {
93
- const { ctx: clientCtx, groups } = groupContext('tls.client');
94
- const issued = await groups.clientCertificate(env.tls.client.vaultPki, env.tls.client.commonName, clientCtx);
95
- if (!issued.cert.includes('-----BEGIN') || !issued.key.includes('-----BEGIN')) {
96
- throw fail('tls.client', 'Vault did not issue PEM text');
97
- }
98
- tls.cert = issued.cert;
99
- tls.key = issued.key;
100
- // The issuing CA stands in for a ca that was not written in the file.
101
- if (tls.ca === undefined && issued.ca)
102
- tls.ca = issued.ca;
103
- }
104
62
  if (env.tls.cert !== undefined)
105
63
  tls.cert = await pem('tls.cert', env.tls.cert);
106
64
  if (env.tls.key !== undefined)
@@ -143,19 +101,9 @@ export function describeEnvironment(env, registry = defaultRegistry()) {
143
101
  add('port', env.port);
144
102
  add('database', env.database);
145
103
  add('schema', env.schema);
146
- if (env.credentials) {
147
- labels.username = `vault database: ${env.credentials.vaultDatabase}`;
148
- labels.password = labels.username;
149
- }
150
- else {
151
- add('username', env.username);
152
- if (env.auth === 'password')
153
- add('password', env.password);
154
- }
155
- if (env.tls.client) {
156
- labels['tls.cert'] = `vault pki: ${env.tls.client.vaultPki}`;
157
- labels['tls.key'] = labels['tls.cert'];
158
- }
104
+ add('username', env.username);
105
+ if (env.auth === 'password')
106
+ add('password', env.password);
159
107
  add('tls.ca', env.tls.ca);
160
108
  add('tls.cert', env.tls.cert);
161
109
  add('tls.key', env.tls.key);
@@ -17,7 +17,7 @@ export const createRegistry = (resolvers) => new Map(resolvers.map((resolver) =>
17
17
  // `prompter` enables `prompt` values and `store` `stored` ones; without them those
18
18
  // fail with "no resolver".
19
19
  export const defaultRegistry = (environment, prompter, store,
20
- // Share one Vault client (its token and leases) with whoever needs the group resolvers.
20
+ // Share one Vault client (its login token) with whoever else reads from Vault.
21
21
  vault = createVaultClient({ env: environment })) => createRegistry([
22
22
  literalResolver,
23
23
  createEnvResolver(environment),
@@ -115,11 +115,11 @@ export function createPromptResolver(prompter) {
115
115
  return answer;
116
116
  },
117
117
  confirm(ctx) {
118
- const write = ctx.purpose === 'write';
118
+ const write = ctx.purpose === 'write' || ctx.purpose === 'drop';
119
119
  const remembered = write ? undefined : confirmed.get(ctx.envName);
120
120
  if (remembered)
121
121
  return remembered;
122
- const field = write ? 'write' : 'confirm';
122
+ const field = ctx.purpose === 'drop' ? 'drop' : write ? 'write' : 'confirm';
123
123
  if (!ctx.interactive)
124
124
  return Promise.reject(new PromptRequiredError(ctx.envName, field));
125
125
  const challenge = challengeWords();
@@ -260,88 +260,6 @@ export function createVaultClient(options = {}) {
260
260
  throw fail(ctx, `unexpected response from Vault for ${metadata}`);
261
261
  },
262
262
  };
263
- const leases = new Map();
264
- const issued = new Map();
265
- const text = (ctx, data, key, path) => {
266
- const value = data[key];
267
- if (typeof value !== 'string' || value === '') {
268
- throw fail(ctx, `key ${key} not in the answer of ${path}`);
269
- }
270
- return value;
271
- };
272
- // Asks Vault for something new once per environment and path (every time when `reuse` is false),
273
- // and remembers its lease so release() can revoke it. A lease that has run out is replaced.
274
- const issue = async (ctx, path, request, read, reuse = true) => {
275
- const cacheKey = `${ctx.envName}\0${path}`;
276
- const cached = reuse ? issued.get(cacheKey) : undefined;
277
- if (cached) {
278
- const entry = await cached;
279
- if (entry.expiresAt === undefined || now() < entry.expiresAt)
280
- return entry.values;
281
- if (issued.get(cacheKey) === cached)
282
- issued.delete(cacheKey);
283
- }
284
- const pending = (async () => {
285
- const body = await call(ctx, path, request, 'role');
286
- const values = read((body.data ?? {}));
287
- const leaseId = typeof body.lease_id === 'string' ? body.lease_id : '';
288
- const duration = typeof body.lease_duration === 'number' ? body.lease_duration : 0;
289
- const expiresAt = duration > 0 ? now() + duration * 1000 - expiryMarginMs : undefined;
290
- if (leaseId) {
291
- leases.set(ctx.envName, [...(leases.get(ctx.envName) ?? []), { id: leaseId, expiresAt }]);
292
- }
293
- return { values, expiresAt };
294
- })();
295
- if (reuse) {
296
- issued.set(cacheKey, pending);
297
- pending.catch(() => {
298
- if (issued.get(cacheKey) === pending)
299
- issued.delete(cacheKey);
300
- });
301
- }
302
- return (await pending).values;
303
- };
304
- const revoke = async (envName) => {
305
- const held = leases.get(envName) ?? [];
306
- leases.delete(envName);
307
- for (const key of [...issued.keys()])
308
- if (key.startsWith(`${envName}\0`))
309
- issued.delete(key);
310
- const ctx = { envName, field: 'lease', configDir: '', interactive: false };
311
- for (const lease of held) {
312
- try {
313
- await authed(ctx, 'sys/leases/revoke', { method: 'PUT', body: { lease_id: lease.id } });
314
- }
315
- catch {
316
- // Best effort: a lease that cannot be revoked still ends when its TTL does.
317
- }
318
- }
319
- };
320
- const groups = {
321
- async databaseCredentials(path, ctx) {
322
- // Never reused: a password is asked for again every time it is needed.
323
- const values = await issue(ctx, path, {}, (data) => ({
324
- username: text(ctx, data, 'username', path),
325
- password: text(ctx, data, 'password', path),
326
- }), false);
327
- return { username: values.username, password: values.password };
328
- },
329
- async clientCertificate(path, commonName, ctx) {
330
- const values = await issue(ctx, path, { method: 'POST', body: { common_name: commonName } }, (data) => ({
331
- cert: text(ctx, data, 'certificate', path),
332
- key: text(ctx, data, 'private_key', path),
333
- ...(typeof data.issuing_ca === 'string' && data.issuing_ca !== ''
334
- ? { ca: data.issuing_ca }
335
- : {}),
336
- }));
337
- return { cert: values.cert, key: values.key, ...(values.ca ? { ca: values.ca } : {}) };
338
- },
339
- release: revoke,
340
- releaseAll: async () => {
341
- for (const envName of [...leases.keys()])
342
- await revoke(envName);
343
- },
344
- };
345
263
  const resolver = {
346
264
  kind: 'vault',
347
265
  async resolve(ref, ctx) {
@@ -360,6 +278,6 @@ export function createVaultClient(options = {}) {
360
278
  },
361
279
  describe: ({ vault }) => `vault: ${vault}`,
362
280
  };
363
- return { resolver, groups, kv };
281
+ return { resolver, kv };
364
282
  }
365
283
  export const createVaultResolver = (options = {}) => createVaultClient(options).resolver;
package/dist/db/store.js CHANGED
@@ -82,6 +82,18 @@ const migrations = [
82
82
  error TEXT NULL
83
83
  );
84
84
  CREATE INDEX history_env ON history (env_name, id)`,
85
+ // Queries kept by name, per environment, for the web UI.
86
+ `CREATE TABLE saved_queries (
87
+ id INTEGER PRIMARY KEY,
88
+ env_name TEXT NOT NULL,
89
+ name TEXT NOT NULL,
90
+ folder TEXT NOT NULL DEFAULT '',
91
+ sql TEXT NOT NULL,
92
+ pinned INTEGER NOT NULL DEFAULT 0,
93
+ created_at TEXT NOT NULL,
94
+ updated_at TEXT NOT NULL
95
+ );
96
+ CREATE INDEX saved_queries_env ON saved_queries (env_name)`,
85
97
  ];
86
98
  export const schemaVersion = migrations.length;
87
99
  function migrate(db) {
@@ -102,6 +114,16 @@ function migrate(db) {
102
114
  }
103
115
  }
104
116
  }
117
+ const toSaved = (row) => ({
118
+ id: row.id,
119
+ envName: row.env_name,
120
+ name: row.name,
121
+ folder: row.folder,
122
+ sql: row.sql,
123
+ pinned: row.pinned === 1,
124
+ createdAt: row.created_at,
125
+ updatedAt: row.updated_at,
126
+ });
105
127
  const toHistory = (row) => ({
106
128
  id: row.id,
107
129
  at: row.at,
@@ -203,6 +225,14 @@ export function openStore(dir) {
203
225
  const historyByEnv = database.prepare(`SELECT * FROM history WHERE id IN (
204
226
  SELECT MAX(id) FROM history WHERE env_name = ? GROUP BY sql
205
227
  ) ORDER BY id DESC LIMIT ?`);
228
+ const insertSaved = database.prepare(`INSERT INTO saved_queries (env_name, name, folder, sql, pinned, created_at, updated_at)
229
+ VALUES (:envName, :name, :folder, :sql, :pinned, :now, :now)`);
230
+ const selectSaved = database.prepare('SELECT * FROM saved_queries WHERE id = ?');
231
+ const savedByEnv = database.prepare(`SELECT * FROM saved_queries WHERE env_name = ?
232
+ ORDER BY pinned DESC, folder COLLATE NOCASE, name COLLATE NOCASE, id`);
233
+ const changeSaved = database.prepare(`UPDATE saved_queries SET name = :name, folder = :folder, sql = :sql, pinned = :pinned,
234
+ updated_at = :now WHERE id = :id`);
235
+ const removeSaved = database.prepare('DELETE FROM saved_queries WHERE id = ?');
206
236
  const selectSetting = database.prepare('SELECT value FROM settings WHERE key = ?');
207
237
  const upsertSetting = database.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value');
208
238
  const selectSecret = database.prepare('SELECT value FROM secrets WHERE key = ?');
@@ -246,6 +276,33 @@ export function openStore(dir) {
246
276
  return toHistory(selectHistory.get(Number(lastInsertRowid)));
247
277
  },
248
278
  historyFor: (envName, limit = 200) => historyByEnv.all(envName, limit).map(toHistory),
279
+ savedQueries: (envName) => savedByEnv.all(envName).map(toSaved),
280
+ saveQuery: (input) => {
281
+ const { lastInsertRowid } = insertSaved.run({
282
+ envName: input.envName,
283
+ name: input.name,
284
+ folder: input.folder ?? '',
285
+ sql: input.sql,
286
+ pinned: input.pinned ? 1 : 0,
287
+ now: now(),
288
+ });
289
+ return toSaved(selectSaved.get(Number(lastInsertRowid)));
290
+ },
291
+ updateSavedQuery: (id, changes) => {
292
+ const row = selectSaved.get(id);
293
+ if (!row)
294
+ return undefined;
295
+ changeSaved.run({
296
+ id,
297
+ name: changes.name ?? row.name,
298
+ folder: changes.folder ?? row.folder,
299
+ sql: changes.sql ?? row.sql,
300
+ pinned: (changes.pinned ?? row.pinned === 1) ? 1 : 0,
301
+ now: now(),
302
+ });
303
+ return toSaved(selectSaved.get(id));
304
+ },
305
+ deleteSavedQuery: (id) => Number(removeSaved.run(id).changes) > 0,
249
306
  getSetting: (key) => selectSetting.get(key)?.value,
250
307
  setSetting: (key, value) => {
251
308
  upsertSetting.run(key, value);
@@ -34,8 +34,10 @@ export function listEnvironments(services) {
34
34
  tier: config.tier,
35
35
  protected: config.protected,
36
36
  readOnly: config.readOnly,
37
+ seeder: config.seeder,
38
+ designer: config.designer,
37
39
  tlsMode: config.tls.mode,
38
- user: config.username === undefined ? '‹vault›' : display(config.username),
40
+ user: display(config.username),
39
41
  host: display(config.host),
40
42
  port: display(config.port),
41
43
  database: display(config.database),
@@ -49,6 +51,8 @@ export function listEnvironments(services) {
49
51
  tier: 'local',
50
52
  protected: false,
51
53
  readOnly: false,
54
+ seeder: true,
55
+ designer: true,
52
56
  tlsMode: connection.ssl ? 'require' : 'disable',
53
57
  user: connection.username,
54
58
  host: connection.host,
@@ -69,7 +73,6 @@ options = {}) {
69
73
  return resolveEnvironment(config, {
70
74
  interactive: options.interactive ?? true,
71
75
  registry: services.resolvers,
72
- groups: services.groups,
73
76
  signal: options.signal,
74
77
  });
75
78
  }