pg-admin-mcp 0.0.0-stage → 0.1.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/README.md +608 -2
- package/dist/config/env.js +67 -0
- package/dist/config/env.js.map +1 -0
- package/dist/config/profiles.js +257 -0
- package/dist/config/profiles.js.map +1 -0
- package/dist/core/audit-context.js +21 -0
- package/dist/core/audit-context.js.map +1 -0
- package/dist/core/audit.js +91 -0
- package/dist/core/audit.js.map +1 -0
- package/dist/core/brands.js +24 -0
- package/dist/core/brands.js.map +1 -0
- package/dist/core/classifier.js +279 -0
- package/dist/core/classifier.js.map +1 -0
- package/dist/core/errors.js +197 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/format.js +258 -0
- package/dist/core/format.js.map +1 -0
- package/dist/core/guards.js +513 -0
- package/dist/core/guards.js.map +1 -0
- package/dist/core/identifiers.js +431 -0
- package/dist/core/identifiers.js.map +1 -0
- package/dist/core/limits.js +79 -0
- package/dist/core/limits.js.map +1 -0
- package/dist/core/logger.js +34 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/paginate.js +195 -0
- package/dist/core/paginate.js.map +1 -0
- package/dist/core/paths.js +392 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/policy.js +98 -0
- package/dist/core/policy.js.map +1 -0
- package/dist/core/shutdown.js +10 -0
- package/dist/core/shutdown.js.map +1 -0
- package/dist/core/stdout-guard.js +25 -0
- package/dist/core/stdout-guard.js.map +1 -0
- package/dist/db/catalog.js +1006 -0
- package/dist/db/catalog.js.map +1 -0
- package/dist/db/pool.js +158 -0
- package/dist/db/pool.js.map +1 -0
- package/dist/db/session.js +258 -0
- package/dist/db/session.js.map +1 -0
- package/dist/db/types.js +2 -0
- package/dist/db/types.js.map +1 -0
- package/dist/index.js +91 -0
- package/dist/index.js.map +1 -0
- package/dist/resources/index.js +88 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/runtime/binaries.js +356 -0
- package/dist/runtime/binaries.js.map +1 -0
- package/dist/runtime/env-file.js +62 -0
- package/dist/runtime/env-file.js.map +1 -0
- package/dist/runtime/environment.js +34 -0
- package/dist/runtime/environment.js.map +1 -0
- package/dist/server.js +106 -0
- package/dist/server.js.map +1 -0
- package/dist/snapshot/capture.js +189 -0
- package/dist/snapshot/capture.js.map +1 -0
- package/dist/snapshot/diff.js +254 -0
- package/dist/snapshot/diff.js.map +1 -0
- package/dist/snapshot/migrate.js +1596 -0
- package/dist/snapshot/migrate.js.map +1 -0
- package/dist/snapshot/model.js +80 -0
- package/dist/snapshot/model.js.map +1 -0
- package/dist/tools/_archive_meta.js +138 -0
- package/dist/tools/_archive_meta.js.map +1 -0
- package/dist/tools/_batch_shared.js +73 -0
- package/dist/tools/_batch_shared.js.map +1 -0
- package/dist/tools/_export_format.js +107 -0
- package/dist/tools/_export_format.js.map +1 -0
- package/dist/tools/_object_kinds.js +97 -0
- package/dist/tools/_object_kinds.js.map +1 -0
- package/dist/tools/_relation_detail.js +55 -0
- package/dist/tools/_relation_detail.js.map +1 -0
- package/dist/tools/_shared.js +17 -0
- package/dist/tools/_shared.js.map +1 -0
- package/dist/tools/_snapshot_io.js +238 -0
- package/dist/tools/_snapshot_io.js.map +1 -0
- package/dist/tools/admin/_shared.js +18 -0
- package/dist/tools/admin/_shared.js.map +1 -0
- package/dist/tools/admin/extensions.js +80 -0
- package/dist/tools/admin/extensions.js.map +1 -0
- package/dist/tools/admin/grants.js +207 -0
- package/dist/tools/admin/grants.js.map +1 -0
- package/dist/tools/admin/index.js +138 -0
- package/dist/tools/admin/index.js.map +1 -0
- package/dist/tools/admin/maintenance.js +106 -0
- package/dist/tools/admin/maintenance.js.map +1 -0
- package/dist/tools/admin/roles.js +144 -0
- package/dist/tools/admin/roles.js.map +1 -0
- package/dist/tools/admin/sessions.js +104 -0
- package/dist/tools/admin/sessions.js.map +1 -0
- package/dist/tools/admin/stats.js +63 -0
- package/dist/tools/admin/stats.js.map +1 -0
- package/dist/tools/backup.js +288 -0
- package/dist/tools/backup.js.map +1 -0
- package/dist/tools/connection_status.js +138 -0
- package/dist/tools/connection_status.js.map +1 -0
- package/dist/tools/copy.js +355 -0
- package/dist/tools/copy.js.map +1 -0
- package/dist/tools/delete_batch.js +73 -0
- package/dist/tools/delete_batch.js.map +1 -0
- package/dist/tools/describe_object.js +87 -0
- package/dist/tools/describe_object.js.map +1 -0
- package/dist/tools/execute_sql.js +395 -0
- package/dist/tools/execute_sql.js.map +1 -0
- package/dist/tools/explain.js +134 -0
- package/dist/tools/explain.js.map +1 -0
- package/dist/tools/export.js +215 -0
- package/dist/tools/export.js.map +1 -0
- package/dist/tools/get_schema.js +148 -0
- package/dist/tools/get_schema.js.map +1 -0
- package/dist/tools/index.js +40 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/insert_batch.js +212 -0
- package/dist/tools/insert_batch.js.map +1 -0
- package/dist/tools/list_objects.js +160 -0
- package/dist/tools/list_objects.js.map +1 -0
- package/dist/tools/prepare_migration.js +112 -0
- package/dist/tools/prepare_migration.js.map +1 -0
- package/dist/tools/restore.js +301 -0
- package/dist/tools/restore.js.map +1 -0
- package/dist/tools/schema_diff.js +79 -0
- package/dist/tools/schema_diff.js.map +1 -0
- package/dist/tools/schema_snapshot.js +57 -0
- package/dist/tools/schema_snapshot.js.map +1 -0
- package/dist/tools/select.js +162 -0
- package/dist/tools/select.js.map +1 -0
- package/dist/tools/update_batch.js +154 -0
- package/dist/tools/update_batch.js.map +1 -0
- package/package.json +47 -5
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { readInt } from '../config/env.js';
|
|
4
|
+
import { loadProfiles } from '../config/profiles.js';
|
|
5
|
+
import { PgMcpError, toToolResult } from '../core/errors.js';
|
|
6
|
+
import { formatOutcome } from '../core/format.js';
|
|
7
|
+
import { resolveWorkspaceFileForRead } from '../core/paths.js';
|
|
8
|
+
import { capabilityEnvVar, requireCapability } from '../core/policy.js';
|
|
9
|
+
import { assertArchiveReadable, assertClientCoversServer, connectionArgs, parseMajor, requireBinary, runBinary, } from '../runtime/binaries.js';
|
|
10
|
+
import { readSidecar, serverVersionOf, sidecarPathFor } from './_archive_meta.js';
|
|
11
|
+
import { defineTool, profileParam } from './_shared.js';
|
|
12
|
+
/**
|
|
13
|
+
* `postgres_restore` — **the most destructive tool in this server.**
|
|
14
|
+
* `pg_restore` can drop and recreate every object in the target database, and
|
|
15
|
+
* with `clean: true` it does exactly that. There is no undo.
|
|
16
|
+
*
|
|
17
|
+
* ## It does NOT compose `runGuardedMutation`, and here is why
|
|
18
|
+
*
|
|
19
|
+
* Stated plainly because the alternative — a tool that *looks* guarded and is
|
|
20
|
+
* not — is worse than an unguarded one. Every other mutating tool in this
|
|
21
|
+
* server goes through `core/guards.ts#runGuardedMutation`, which previews a
|
|
22
|
+
* change by running it inside a transaction this server opened and then rolling
|
|
23
|
+
* back, and confirms it by re-running it and comparing a row count and a key
|
|
24
|
+
* checksum *inside the committing transaction*. Not one part of that is
|
|
25
|
+
* available here:
|
|
26
|
+
*
|
|
27
|
+
* - **There is no transaction we control.** `pg_restore` is a separate process
|
|
28
|
+
* that opens its own connections. `withTx`'s `BEGIN`/`ROLLBACK` are on a
|
|
29
|
+
* different backend entirely and could not roll back a single thing the child
|
|
30
|
+
* did. `pg_restore --single-transaction` is the child's own transaction,
|
|
31
|
+
* committed by the child before it exits — we cannot inspect it, let alone
|
|
32
|
+
* abort it.
|
|
33
|
+
* - **There is no row count.** `pg_restore` reports nothing comparable; a
|
|
34
|
+
* restore is a stream of DDL and COPY, not one statement with a result.
|
|
35
|
+
* - **There is no `sqlHash`.** The unit of work is an archive file, not SQL we
|
|
36
|
+
* authored, so there is no normalized statement to hash and nothing for
|
|
37
|
+
* `expect` to pin.
|
|
38
|
+
*
|
|
39
|
+
* So: same shape as `tools/admin/maintenance.ts`'s VACUUM — outside the guard,
|
|
40
|
+
* because the guard is *not possible* here, not because it was inconvenient.
|
|
41
|
+
* What replaces it, and what the tool actually enforces:
|
|
42
|
+
*
|
|
43
|
+
* 1. **`mode: "inspect"` is the dry run.** `pg_restore --list` reads the
|
|
44
|
+
* archive's table of contents and touches no database at all — it is not a
|
|
45
|
+
* weakened preview, it is a complete and truthful account of what the
|
|
46
|
+
* archive contains. It is also the default: an omitted `mode` inspects.
|
|
47
|
+
* 2. **`mode: "restore"` requires `confirm: true`**, in every environment
|
|
48
|
+
* class, with no `expect` token — because there is no honest token to
|
|
49
|
+
* issue. A restore without `confirm` changes nothing and returns the
|
|
50
|
+
* inspection instead.
|
|
51
|
+
* 3. **The `restore` capability** (`PG_<ALIAS>_ALLOW_RESTORE`) is off by
|
|
52
|
+
* default, like every other write capability.
|
|
53
|
+
*
|
|
54
|
+
* ## The archive is untrusted operator input
|
|
55
|
+
*
|
|
56
|
+
* It is a file path handed in by whoever is driving the agent, so it goes
|
|
57
|
+
* through `core/paths.ts#resolveWorkspaceFileForRead` — the same one policy
|
|
58
|
+
* `postgres_copy`'s import direction and the snapshot reader use: allow-list
|
|
59
|
+
* containment, a second resolve after `realpath` so a symlink cannot lead out
|
|
60
|
+
* of the workspace, open-once plus an `ino` check so the file cannot be swapped
|
|
61
|
+
* after it was approved, and a size cap (`PG_MCP_MAX_ARCHIVE_BYTES`) enforced
|
|
62
|
+
* from the open handle's `fstat`.
|
|
63
|
+
*
|
|
64
|
+
* *Documented residual gap:* `pg_restore` takes a **path**, not a file
|
|
65
|
+
* descriptor, so it re-opens the file by name. This module holds its own handle
|
|
66
|
+
* open for the whole restore, which keeps the approved inode alive, but a
|
|
67
|
+
* rename of the path between the check and the child's open would still send
|
|
68
|
+
* `pg_restore` at a different file. Closing that completely would mean piping
|
|
69
|
+
* the archive to the child's stdin, which `pg_restore` supports only for
|
|
70
|
+
* single-file custom archives and only without parallelism. The exposure is an
|
|
71
|
+
* operator racing their own workspace; the containment check is what stops a
|
|
72
|
+
* *planted* symlink, and that one is closed.
|
|
73
|
+
*
|
|
74
|
+
* ## Forward-only archives (design.md risk 10)
|
|
75
|
+
*
|
|
76
|
+
* `pg_dump 17` dumps a 16 server fine, but a custom-format archive written by
|
|
77
|
+
* 17 can never be read by `pg_restore 16`. The archive major comes from the
|
|
78
|
+
* sidecar `postgres_backup` wrote (checked before anything is spawned) or, for
|
|
79
|
+
* a hand-made archive, from the `Dumped by pg_dump version:` line in
|
|
80
|
+
* `pg_restore --list`'s own header. Either way the refusal names the image tag
|
|
81
|
+
* that *can* read it, instead of surfacing `unsupported version (1.16) in file
|
|
82
|
+
* header`.
|
|
83
|
+
*/
|
|
84
|
+
/**
|
|
85
|
+
* 20 GiB. Unlike `PG_MCP_MAX_IMPORT_BYTES` this is not a throughput bound —
|
|
86
|
+
* nothing here reads the archive into this process at all, `pg_restore` does —
|
|
87
|
+
* it is a sanity bound on what an operator can point this tool at, and archives
|
|
88
|
+
* are legitimately much larger than a CSV import.
|
|
89
|
+
*/
|
|
90
|
+
const DEFAULT_MAX_ARCHIVE_BYTES = 20 * 1024 * 1024 * 1024;
|
|
91
|
+
function maxArchiveBytes() {
|
|
92
|
+
return readInt(process.env, 'PG_MCP_MAX_ARCHIVE_BYTES', { default: DEFAULT_MAX_ARCHIVE_BYTES, min: 1 });
|
|
93
|
+
}
|
|
94
|
+
const inputSchema = z.object({
|
|
95
|
+
...profileParam,
|
|
96
|
+
path: z
|
|
97
|
+
.string()
|
|
98
|
+
.min(1)
|
|
99
|
+
.describe('Archive written by pg_dump in custom or tar format. Relative paths resolve against the profile\'s first allowed directory; absolute paths must still land inside one.'),
|
|
100
|
+
mode: z
|
|
101
|
+
.enum(['inspect', 'restore'])
|
|
102
|
+
.optional()
|
|
103
|
+
.describe('inspect (default) — list the archive contents, touching no database. restore — apply it. THIS OVERWRITES DATA.'),
|
|
104
|
+
confirm: z
|
|
105
|
+
.boolean()
|
|
106
|
+
.optional()
|
|
107
|
+
.describe('Required (true) for mode:"restore". There is no dry-run/expect token here: pg_restore is a separate process with no transaction this server can roll back. Use mode:"inspect" to see what the archive holds first.'),
|
|
108
|
+
clean: z
|
|
109
|
+
.boolean()
|
|
110
|
+
.optional()
|
|
111
|
+
.describe('pg_restore --clean: DROP every object before recreating it. THE MOST DESTRUCTIVE OPTION IN THIS SERVER — existing data in the target objects is gone.'),
|
|
112
|
+
noOwner: z.boolean().optional().describe('Ignore ownership commands in the archive; objects end up owned by the connecting role.'),
|
|
113
|
+
singleTransaction: z
|
|
114
|
+
.boolean()
|
|
115
|
+
.optional()
|
|
116
|
+
.describe('pg_restore --single-transaction: all-or-nothing inside the CHILD process\'s own transaction. It reduces the blast radius of a partial failure; it is not a dry run and this server cannot roll it back.'),
|
|
117
|
+
schemas: z.array(z.string().min(1)).optional().describe('Restore only these schemas from the archive (pg_restore --schema).'),
|
|
118
|
+
tables: z.array(z.string().min(1)).optional().describe('Restore only these tables from the archive (pg_restore --table).'),
|
|
119
|
+
timeoutMs: z
|
|
120
|
+
.number()
|
|
121
|
+
.int()
|
|
122
|
+
.min(1)
|
|
123
|
+
.optional()
|
|
124
|
+
.describe('Wall-clock ceiling for the pg_restore child, clamped to PG_<ALIAS>_BACKUP_TIMEOUT_MAX_MS (1 h by default).'),
|
|
125
|
+
});
|
|
126
|
+
/** `; Dumped by pg_dump version: 17.5` — the archive's own header, the fallback when there is no sidecar. */
|
|
127
|
+
export function archiveMajorFromListing(listing) {
|
|
128
|
+
const line = /Dumped by pg_dump version:\s*(\S+)/.exec(listing);
|
|
129
|
+
return line?.[1] === undefined ? undefined : parseMajor(line[1]);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* `pg_restore`'s own refusal when the archive is too new for it. Recognized so
|
|
133
|
+
* the operator gets the image-tag remedy even for a hand-made archive with no
|
|
134
|
+
* sidecar, where the major could not be read up front — the failure is the
|
|
135
|
+
* only place the truth surfaces.
|
|
136
|
+
*/
|
|
137
|
+
function isUnsupportedVersion(err) {
|
|
138
|
+
return err instanceof PgMcpError && /unsupported version|not supported by this version/i.test(err.message);
|
|
139
|
+
}
|
|
140
|
+
function tooNewForLocalRestore(archivePath, restore, cause) {
|
|
141
|
+
return new PgMcpError({
|
|
142
|
+
code: 'BINARY_MISSING',
|
|
143
|
+
message: `"${archivePath}" was written by a newer pg_dump than the local pg_restore (${restore.major}), which cannot read it`,
|
|
144
|
+
hint: 'archives are forward-only: run the pg-admin-mcp image tag matching the pg_dump major that wrote this archive ' +
|
|
145
|
+
'(pg-admin-mcp:pg<N>), or point PG_MCP_PG_BIN_DIR at that postgresql-client version',
|
|
146
|
+
details: { archivePath, restoreMajor: restore.major },
|
|
147
|
+
cause,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
function listArgs(archivePath) {
|
|
151
|
+
return ['--list', archivePath];
|
|
152
|
+
}
|
|
153
|
+
export function restoreArgs(profile, args, archivePath) {
|
|
154
|
+
return [
|
|
155
|
+
...connectionArgs(profile),
|
|
156
|
+
`--dbname=${profile.database}`,
|
|
157
|
+
...(args.clean === true ? ['--clean', '--if-exists'] : []),
|
|
158
|
+
...(args.noOwner === true ? ['--no-owner'] : []),
|
|
159
|
+
...(args.singleTransaction === true ? ['--single-transaction'] : []),
|
|
160
|
+
...(args.schemas ?? []).map((s) => `--schema=${s}`),
|
|
161
|
+
...(args.tables ?? []).map((t) => `--table=${t}`),
|
|
162
|
+
// Last, and always an absolute resolved path, so it can never be read as a flag.
|
|
163
|
+
archivePath,
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
/** The archive's TOC, as `pg_restore --list` prints it. Touches no database. */
|
|
167
|
+
async function inspect(archivePath, restore, timeoutMs) {
|
|
168
|
+
try {
|
|
169
|
+
const { stdout } = await runBinary('pg_restore', restore.command, listArgs(archivePath), {
|
|
170
|
+
timeoutMs,
|
|
171
|
+
operation: 'restore inspect',
|
|
172
|
+
stdoutMaxBytes: 4 * 1024 * 1024,
|
|
173
|
+
});
|
|
174
|
+
return stdout;
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
if (isUnsupportedVersion(err))
|
|
178
|
+
throw tooNewForLocalRestore(archivePath, restore, err);
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function confirmRequired(profile, archivePath, clean) {
|
|
183
|
+
return new PgMcpError({
|
|
184
|
+
code: 'GUARD_CONFIRM_REQUIRED',
|
|
185
|
+
message: `restoring "${archivePath}" into profile "${profile.alias}" (${profile.database} on ${profile.host}) would ` +
|
|
186
|
+
`${clean ? 'DROP and recreate every object in the archive' : 'overwrite the objects in the archive'}, and nothing was changed`,
|
|
187
|
+
hint: 'run this again with mode:"inspect" to see exactly what the archive contains, then re-run with confirm:true to ' +
|
|
188
|
+
'apply it. There is no expect token for a restore: pg_restore runs in a separate process with no transaction ' +
|
|
189
|
+
'this server can preview or roll back',
|
|
190
|
+
details: { profile: profile.alias, database: profile.database, archivePath, clean },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
export const restoreTool = defineTool('postgres_restore', {
|
|
194
|
+
title: 'Inspect or restore a pg_dump archive (DESTRUCTIVE)',
|
|
195
|
+
description: 'THE MOST DESTRUCTIVE TOOL IN THIS SERVER. mode:"restore" runs pg_restore against the profile\'s database and can ' +
|
|
196
|
+
'overwrite or, with clean:true, DROP AND RECREATE every object in the archive. There is no undo and no rollback: ' +
|
|
197
|
+
'pg_restore is a separate process, so this tool is NOT wrapped in the dry-run/confirm transaction guard the other ' +
|
|
198
|
+
'mutating tools use — there is no transaction this server can roll back, no row count to compare and no expect ' +
|
|
199
|
+
'token to issue. What it has instead: mode:"inspect" (the default) lists the archive contents with ' +
|
|
200
|
+
'pg_restore --list and touches no database, and mode:"restore" refuses without an explicit confirm:true. ' +
|
|
201
|
+
'Requires PG_<ALIAS>_ALLOW_RESTORE=true. The archive must be inside an allowed directory, is size-capped by ' +
|
|
202
|
+
'PG_MCP_MAX_ARCHIVE_BYTES, and is refused when it was written by a NEWER pg_dump than the local pg_restore ' +
|
|
203
|
+
'(archives are forward-only) with the image tag that can read it. Plain-format (SQL text) dumps are not ' +
|
|
204
|
+
'pg_restore archives — feed those to psql.',
|
|
205
|
+
inputSchema,
|
|
206
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
207
|
+
}, async (args) => {
|
|
208
|
+
try {
|
|
209
|
+
const profile = loadProfiles().get(args.profile);
|
|
210
|
+
// Even inspect needs the capability: reading an archive's table of
|
|
211
|
+
// contents is the first step of a restore, and a profile that may never
|
|
212
|
+
// restore has no business being handed one.
|
|
213
|
+
requireCapability('restore', profile);
|
|
214
|
+
const cap = maxArchiveBytes();
|
|
215
|
+
const source = await resolveWorkspaceFileForRead(args.path, profile.allowedDirs, {
|
|
216
|
+
maxBytes: cap,
|
|
217
|
+
limitLabel: 'archive read',
|
|
218
|
+
limitHint: `raise PG_MCP_MAX_ARCHIVE_BYTES (currently ${cap}) and restart the server, or restore from a smaller archive`,
|
|
219
|
+
missingHint: 'a restore reads an existing archive — check the path, and that it is inside an allowed directory',
|
|
220
|
+
notFileHint: 'point path at a single-file archive (pg_dump --format=custom or tar). Directory-format archives are not supported here: ' +
|
|
221
|
+
'they cannot be identity-checked as one file, so re-dump with --format=custom',
|
|
222
|
+
});
|
|
223
|
+
try {
|
|
224
|
+
const pgRestore = await requireBinary('pg_restore');
|
|
225
|
+
const timeoutMs = Math.min(args.timeoutMs ?? profile.backupTimeoutMaxMs, profile.backupTimeoutMaxMs);
|
|
226
|
+
// Before anything is spawned, when the sidecar is there to say so.
|
|
227
|
+
const sidecar = await readSidecar(source.path, profile.allowedDirs);
|
|
228
|
+
if (sidecar !== undefined)
|
|
229
|
+
assertArchiveReadable(sidecar.clientMajor, pgRestore, source.path);
|
|
230
|
+
const listing = await inspect(source.path, pgRestore, timeoutMs);
|
|
231
|
+
// Fallback for an archive with no sidecar: the TOC header carries the
|
|
232
|
+
// version that wrote it. Only reachable when --list itself succeeded,
|
|
233
|
+
// i.e. when the archive is readable but may still be too new to apply.
|
|
234
|
+
const listedMajor = archiveMajorFromListing(listing);
|
|
235
|
+
if (sidecar === undefined && listedMajor !== undefined) {
|
|
236
|
+
assertArchiveReadable(listedMajor, pgRestore, source.path);
|
|
237
|
+
}
|
|
238
|
+
const entries = listing.split('\n').filter((line) => line.trim() !== '' && !line.startsWith(';'));
|
|
239
|
+
if (args.mode !== 'restore') {
|
|
240
|
+
return formatOutcome({
|
|
241
|
+
kind: 'report',
|
|
242
|
+
data: {
|
|
243
|
+
mode: 'inspect',
|
|
244
|
+
path: source.path,
|
|
245
|
+
bytes: source.bytes,
|
|
246
|
+
archiveMajor: sidecar?.clientMajor ?? listedMajor ?? null,
|
|
247
|
+
sidecar: sidecar ?? null,
|
|
248
|
+
sidecarPath: sidecarPathFor(source.path),
|
|
249
|
+
entryCount: entries.length,
|
|
250
|
+
entries,
|
|
251
|
+
note: 'Nothing was changed — this is pg_restore --list, which reads the archive only. Re-run with ' +
|
|
252
|
+
'mode:"restore" and confirm:true to apply it.',
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
if (args.confirm !== true)
|
|
257
|
+
throw confirmRequired(profile, source.path, args.clean === true);
|
|
258
|
+
// design.md risk 2, on the restoring side: a pg_restore older than the
|
|
259
|
+
// target server refuses to run, and the raw stderr says nothing useful.
|
|
260
|
+
const server = await serverVersionOf(profile, 'restore');
|
|
261
|
+
assertClientCoversServer(pgRestore, server.num, server.version, profile.alias);
|
|
262
|
+
const result = await runBinary('pg_restore', pgRestore.command, restoreArgs(profile, args, source.path), {
|
|
263
|
+
timeoutMs,
|
|
264
|
+
password: profile.password(),
|
|
265
|
+
operation: 'restore',
|
|
266
|
+
});
|
|
267
|
+
return formatOutcome({
|
|
268
|
+
kind: 'report',
|
|
269
|
+
data: {
|
|
270
|
+
mode: 'restore',
|
|
271
|
+
restored: true,
|
|
272
|
+
path: source.path,
|
|
273
|
+
database: profile.database,
|
|
274
|
+
host: `${profile.host}:${profile.port}`,
|
|
275
|
+
archiveMajor: sidecar?.clientMajor ?? listedMajor ?? null,
|
|
276
|
+
restoreMajor: pgRestore.major,
|
|
277
|
+
serverVersion: server.version,
|
|
278
|
+
entryCount: entries.length,
|
|
279
|
+
clean: args.clean === true,
|
|
280
|
+
singleTransaction: args.singleTransaction === true,
|
|
281
|
+
// pg_restore writes warnings here even on success (missing roles,
|
|
282
|
+
// already-existing objects under --clean --if-exists). Surfaced
|
|
283
|
+
// rather than swallowed: they are the only account of what did not
|
|
284
|
+
// apply cleanly.
|
|
285
|
+
warnings: result.stderr.trim() === '' ? [] : result.stderr.trim().split('\n').slice(-40),
|
|
286
|
+
capability: capabilityEnvVar('restore', profile.alias),
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
// Held open for the whole restore so the approved inode stays alive
|
|
292
|
+
// while pg_restore reads it — see the file header's note on the
|
|
293
|
+
// residual path-vs-fd gap.
|
|
294
|
+
await source.handle.close();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
return toToolResult(err);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
//# sourceMappingURL=restore.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"restore.js","sourceRoot":"","sources":["../../src/tools/restore.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { loadProfiles } from '../config/profiles.js';
|
|
3
|
+
import { toToolResult } from '../core/errors.js';
|
|
4
|
+
import { formatOutcome } from '../core/format.js';
|
|
5
|
+
import { resolveLimits, truncateRows } from '../core/limits.js';
|
|
6
|
+
import { diffSnapshots } from '../snapshot/diff.js';
|
|
7
|
+
import { defineTool, profileParam } from './_shared.js';
|
|
8
|
+
import { resolveSnapshotSource, snapshotSourceSchema } from './_snapshot_io.js';
|
|
9
|
+
/**
|
|
10
|
+
* `postgres_schema_diff` — structural diff between two normalized schema
|
|
11
|
+
* snapshots (`snapshot/diff.ts#diffSnapshots`). Each side (`before`/`after`)
|
|
12
|
+
* is independently either a live database capture or a snapshot file written
|
|
13
|
+
* by `postgres_schema_snapshot` (`_snapshot_io.ts#resolveSnapshotSource`), so
|
|
14
|
+
* this covers all three shapes the phase asked for: two files, a file vs. a
|
|
15
|
+
* live database, or two live databases.
|
|
16
|
+
*
|
|
17
|
+
* A snapshot file is untrusted input — `_snapshot_io.ts#parseSnapshotFile`
|
|
18
|
+
* validates it against a `.strict()` zod mirror of `snapshot/model.ts` before
|
|
19
|
+
* it ever reaches `diffSnapshots`, and `diffSnapshots` itself refuses two
|
|
20
|
+
* snapshots of different `formatVersion`s (`SNAPSHOT_VERSION_MISMATCH`) rather
|
|
21
|
+
* than reporting every object as changed.
|
|
22
|
+
*
|
|
23
|
+
* Output is bounded the same way `postgres_get_schema` bounds its object
|
|
24
|
+
* list: `core/limits.ts#truncateRows` over the flat change list, byte-capped
|
|
25
|
+
* by the real serialized size of each entry, with an explicit count of what
|
|
26
|
+
* was kept vs. cut and how to narrow (`before`/`after` `schemas` scope).
|
|
27
|
+
*/
|
|
28
|
+
const inputSchema = z.object({
|
|
29
|
+
...profileParam,
|
|
30
|
+
before: snapshotSourceSchema.describe('The baseline side of the comparison.'),
|
|
31
|
+
after: snapshotSourceSchema.describe('The changed side of the comparison — the diff reads as "before -> after".'),
|
|
32
|
+
maxChanges: z
|
|
33
|
+
.number()
|
|
34
|
+
.int()
|
|
35
|
+
.positive()
|
|
36
|
+
.optional()
|
|
37
|
+
.describe('Caps the number of changes included before truncation. Defaults to the profile row limit.'),
|
|
38
|
+
});
|
|
39
|
+
/** An explicit, actionable truncation message: what was kept, what was cut, how to narrow. */
|
|
40
|
+
function truncationHint(kept, total, cutAt) {
|
|
41
|
+
const at = cutAt === undefined ? 'the cap' : `${cutAt.path} (${cutAt.objectType} ${cutAt.kind})`;
|
|
42
|
+
return `showing ${kept} of ${total} changes; truncated at ${at} — narrow before/after with a schemas scope, or raise maxChanges`;
|
|
43
|
+
}
|
|
44
|
+
export const schemaDiffTool = defineTool('postgres_schema_diff', {
|
|
45
|
+
title: 'Diff two PostgreSQL schema snapshots',
|
|
46
|
+
description: 'Structural diff between two schema snapshots. Each of before/after is independently either kind:"live" (a ' +
|
|
47
|
+
'fresh capture) or kind:"file" (a file written earlier by postgres_schema_snapshot) — so this compares two ' +
|
|
48
|
+
'files, a file against a live database, or two live databases. Read-only: never modifies either side. An ' +
|
|
49
|
+
'oversized result is truncated with an explicit count and a hint for narrowing scope, never silently cut.',
|
|
50
|
+
inputSchema,
|
|
51
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
52
|
+
}, async (args) => {
|
|
53
|
+
try {
|
|
54
|
+
const registry = loadProfiles();
|
|
55
|
+
const profile = registry.get(args.profile);
|
|
56
|
+
const [before, after] = await Promise.all([
|
|
57
|
+
resolveSnapshotSource(args.before, args.profile),
|
|
58
|
+
resolveSnapshotSource(args.after, args.profile),
|
|
59
|
+
]);
|
|
60
|
+
const changes = diffSnapshots(before, after);
|
|
61
|
+
const limits = resolveLimits(profile.maxRows, args.maxChanges === undefined ? {} : { maxRows: args.maxChanges }, process.env);
|
|
62
|
+
const { rows: kept, truncation } = truncateRows(changes, limits, (c) => JSON.stringify(c));
|
|
63
|
+
const truncated = truncation.reason !== null;
|
|
64
|
+
const data = {
|
|
65
|
+
before: { database: before.database },
|
|
66
|
+
after: { database: after.database },
|
|
67
|
+
changeCount: kept.length,
|
|
68
|
+
totalChanges: changes.length,
|
|
69
|
+
changes: kept,
|
|
70
|
+
truncated,
|
|
71
|
+
...(truncated ? { truncationMessage: truncationHint(kept.length, changes.length, changes[kept.length]) } : {}),
|
|
72
|
+
};
|
|
73
|
+
return formatOutcome({ kind: 'report', data });
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
return toToolResult(err);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
//# sourceMappingURL=schema_diff.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema_diff.js","sourceRoot":"","sources":["../../src/tools/schema_diff.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { finished } from 'node:stream/promises';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { loadProfiles } from '../config/profiles.js';
|
|
4
|
+
import { toToolResult } from '../core/errors.js';
|
|
5
|
+
import { formatOutcome } from '../core/format.js';
|
|
6
|
+
import { renderHostPath, resolveWorkspacePath, writeFileAtomic } from '../core/paths.js';
|
|
7
|
+
import { captureSnapshot } from '../snapshot/capture.js';
|
|
8
|
+
import { defineTool, profileParam } from './_shared.js';
|
|
9
|
+
/**
|
|
10
|
+
* `postgres_schema_snapshot` — captures a normalized schema snapshot
|
|
11
|
+
* (`snapshot/capture.ts`) and persists it to a workspace file, so it can
|
|
12
|
+
* later be compared with `postgres_schema_diff` / `postgres_prepare_migration`
|
|
13
|
+
* without holding the source database open.
|
|
14
|
+
*
|
|
15
|
+
* Same path policy as `postgres_export`: `resolveWorkspacePath` (never a
|
|
16
|
+
* directory as the destination) then `writeFileAtomic` (a `.partial` file
|
|
17
|
+
* published only on success, refuses to clobber an existing file unless
|
|
18
|
+
* `overwrite: true`). The file is one `JSON.stringify` of the snapshot —
|
|
19
|
+
* `model.ts`'s own byte-stability guarantee means two captures of an
|
|
20
|
+
* unchanged database produce the identical file.
|
|
21
|
+
*/
|
|
22
|
+
const inputSchema = z.object({
|
|
23
|
+
...profileParam,
|
|
24
|
+
schemas: z
|
|
25
|
+
.array(z.string().min(1))
|
|
26
|
+
.optional()
|
|
27
|
+
.describe('Restrict the capture to these schemas. Omit for every schema in scope (Profile.schemas, or every non-system schema).'),
|
|
28
|
+
path: z.string().min(1).describe('Destination file path. Relative paths resolve against the profile\'s first allowed directory; absolute paths must still land inside an allowed directory.'),
|
|
29
|
+
overwrite: z.boolean().optional().describe('Required (true) to replace an existing file at path. Defaults to false.'),
|
|
30
|
+
});
|
|
31
|
+
export const schemaSnapshotTool = defineTool('postgres_schema_snapshot', {
|
|
32
|
+
title: 'Capture a normalized schema snapshot to a file',
|
|
33
|
+
description: 'Captures a normalized, byte-stable schema snapshot (tables, views, sequences, functions, enums, extensions ' +
|
|
34
|
+
'— see postgres_get_schema for a live equivalent) and writes it to a workspace file as JSON. Use the result ' +
|
|
35
|
+
'with postgres_schema_diff or postgres_prepare_migration to compare it against another snapshot or a live ' +
|
|
36
|
+
'database. Refuses to overwrite an existing file unless overwrite:true.',
|
|
37
|
+
inputSchema,
|
|
38
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
39
|
+
}, async (args) => {
|
|
40
|
+
try {
|
|
41
|
+
const registry = loadProfiles();
|
|
42
|
+
const profile = registry.get(args.profile);
|
|
43
|
+
const resolvedPath = await resolveWorkspacePath(args.path, profile.allowedDirs);
|
|
44
|
+
const snapshot = await captureSnapshot(profile, args.schemas === undefined ? {} : { schemas: args.schemas });
|
|
45
|
+
const json = JSON.stringify(snapshot);
|
|
46
|
+
const bytes = await writeFileAtomic(resolvedPath, async (dest) => {
|
|
47
|
+
dest.end(json);
|
|
48
|
+
await finished(dest);
|
|
49
|
+
}, { overwrite: args.overwrite === true });
|
|
50
|
+
const hostPath = renderHostPath(resolvedPath, profile.allowedDirs);
|
|
51
|
+
return formatOutcome({ kind: 'file', path: resolvedPath, bytes, ...(hostPath === undefined ? {} : { hostPath }) });
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
return toToolResult(err);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
//# sourceMappingURL=schema_snapshot.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema_snapshot.js","sourceRoot":"","sources":["../../src/tools/schema_snapshot.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { loadProfiles } from '../config/profiles.js';
|
|
3
|
+
import { toToolResult } from '../core/errors.js';
|
|
4
|
+
import { formatOutcome } from '../core/format.js';
|
|
5
|
+
import { columnIdentFromCatalog, resolveColumn, resolveRelation } from '../core/identifiers.js';
|
|
6
|
+
import { resolveLimits } from '../core/limits.js';
|
|
7
|
+
import { buildKeysetWhere, computeShapeHash, cursorValuesFromRow, decodeCursor, encodeCursor, orderByClause, withTiebreaker, } from '../core/paginate.js';
|
|
8
|
+
import { getColumns, getPrimaryKey } from '../db/catalog.js';
|
|
9
|
+
import { withTx } from '../db/session.js';
|
|
10
|
+
import { defineTool, profileParam } from './_shared.js';
|
|
11
|
+
/**
|
|
12
|
+
* `postgres_select` — structured, parameterized, paginated reads. Deliberately
|
|
13
|
+
* NOT an arbitrary-SQL escape hatch (that is `postgres_execute_sql`, Phase 2):
|
|
14
|
+
* every identifier (`columns`, `where[].column`, `orderBy[].column`) is
|
|
15
|
+
* validated against the relation's real columns via `core/identifiers.ts#resolveColumn`
|
|
16
|
+
* before it ever reaches SQL text, and every value is a bound `$N` parameter —
|
|
17
|
+
* never string-concatenated.
|
|
18
|
+
*
|
|
19
|
+
* Runs inside `withTx({ readOnly: true })`: PostgreSQL's own `READ ONLY`
|
|
20
|
+
* transaction (SQLSTATE 25006 -> `POLICY_READ_ONLY`) is the enforcement
|
|
21
|
+
* mechanism, not a lexical check — so a `where` value that calls a volatile
|
|
22
|
+
* writing function is rejected by PostgreSQL itself, not by this tool trying
|
|
23
|
+
* to parse SQL.
|
|
24
|
+
*
|
|
25
|
+
* Pagination is real SQL keyset pagination (unlike `list_objects`'s in-memory
|
|
26
|
+
* one): `core/paginate.ts#SortColumn`/`buildKeysetWhere`/`orderByClause` all
|
|
27
|
+
* resolve NULLS FIRST/LAST placement through the same `effectiveNulls`
|
|
28
|
+
* default, which is the entire mechanism that keeps the emitted `ORDER BY`
|
|
29
|
+
* and the keyset `WHERE` predicate from disagreeing on a nullable sort
|
|
30
|
+
* column — see that module's doc comment for the four-case breakdown.
|
|
31
|
+
*/
|
|
32
|
+
const DEFAULT_PAGE_SIZE = 200;
|
|
33
|
+
const MAX_PAGE_SIZE = 2000;
|
|
34
|
+
/** Exported so `tools/export.ts`, `update_batch` and `delete_batch` reuse the exact same scalar shape instead of a second, later-diverging copy. */
|
|
35
|
+
export const scalarValue = z.union([z.string(), z.number(), z.boolean(), z.null()]);
|
|
36
|
+
/** Exported so `tools/export.ts`, `update_batch` and `delete_batch` reuse the exact same predicate shape and validation instead of a second, later-diverging copy. */
|
|
37
|
+
export const wherePredicate = z.discriminatedUnion('op', [
|
|
38
|
+
z.object({ column: z.string().min(1), op: z.literal('IS NULL') }),
|
|
39
|
+
z.object({ column: z.string().min(1), op: z.literal('IS NOT NULL') }),
|
|
40
|
+
z.object({
|
|
41
|
+
column: z.string().min(1),
|
|
42
|
+
op: z.enum(['=', '!=', '<', '<=', '>', '>=', 'LIKE', 'ILIKE']),
|
|
43
|
+
value: scalarValue,
|
|
44
|
+
}),
|
|
45
|
+
z.object({ column: z.string().min(1), op: z.enum(['IN', 'NOT IN']), value: z.array(scalarValue).min(1) }),
|
|
46
|
+
]);
|
|
47
|
+
const orderByColumn = z.object({
|
|
48
|
+
column: z.string().min(1),
|
|
49
|
+
direction: z.enum(['ASC', 'DESC']).optional().describe('Defaults to ASC.'),
|
|
50
|
+
nulls: z.enum(['FIRST', 'LAST']).optional().describe('Defaults to the direction\'s own PostgreSQL default (ASC -> LAST, DESC -> FIRST).'),
|
|
51
|
+
});
|
|
52
|
+
const inputSchema = z.object({
|
|
53
|
+
...profileParam,
|
|
54
|
+
schema: z.string().min(1).describe('Schema name, e.g. "app".'),
|
|
55
|
+
table: z.string().min(1).describe('Table/view name within the schema.'),
|
|
56
|
+
columns: z.array(z.string().min(1)).min(1).optional().describe('Columns to select, in this order. Omit for every column, in ordinal order.'),
|
|
57
|
+
where: z
|
|
58
|
+
.array(wherePredicate)
|
|
59
|
+
.optional()
|
|
60
|
+
.describe('Filter predicates, ANDed together. Every value is a bound parameter — never interpolated into SQL text.'),
|
|
61
|
+
orderBy: z
|
|
62
|
+
.array(orderByColumn)
|
|
63
|
+
.optional()
|
|
64
|
+
.describe('Sort columns in priority order. A unique tiebreaker (primary key, or ctid) is appended automatically.'),
|
|
65
|
+
limit: z.number().int().min(1).max(MAX_PAGE_SIZE).optional().describe(`Rows per page (max ${MAX_PAGE_SIZE}), further capped by the profile row limit.`),
|
|
66
|
+
cursor: z.string().optional().describe('Opaque pagination cursor — pass back the previous call\'s nextCursor verbatim to fetch the next page.'),
|
|
67
|
+
});
|
|
68
|
+
export const selectTool = defineTool('postgres_select', {
|
|
69
|
+
title: 'Select rows from a PostgreSQL table',
|
|
70
|
+
description: 'Structured, parameterized, paginated read of one table or view — the primary way to fetch actual row data. ' +
|
|
71
|
+
'Filter with a structured `where` (=, !=, <, <=, >, >=, LIKE, ILIKE, IN, NOT IN, IS NULL, IS NOT NULL); sort ' +
|
|
72
|
+
'with `orderBy` (direction and NULLS FIRST/LAST); page with `limit`/`cursor`. Every identifier is validated ' +
|
|
73
|
+
'against the table\'s real columns (unknown column -> IDENTIFIER_UNKNOWN with suggestions); every value is a ' +
|
|
74
|
+
'bound parameter. Runs inside a read-only transaction. Not an arbitrary-SQL tool.',
|
|
75
|
+
inputSchema,
|
|
76
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
77
|
+
}, async (args) => {
|
|
78
|
+
try {
|
|
79
|
+
const registry = loadProfiles();
|
|
80
|
+
const profile = registry.get(args.profile);
|
|
81
|
+
const rel = await resolveRelation(profile, args.schema, args.table);
|
|
82
|
+
const limits = resolveLimits(profile.maxRows, args.limit === undefined ? {} : { maxRows: args.limit }, process.env);
|
|
83
|
+
const pageLimit = Math.min(args.limit ?? DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, limits.maxRows);
|
|
84
|
+
const shapeHash = computeShapeHash({
|
|
85
|
+
profile: profile.alias,
|
|
86
|
+
schema: args.schema,
|
|
87
|
+
table: args.table,
|
|
88
|
+
columns: args.columns ?? null,
|
|
89
|
+
where: args.where ?? null,
|
|
90
|
+
orderBy: args.orderBy ?? null,
|
|
91
|
+
});
|
|
92
|
+
const result = await withTx(profile, async (client) => {
|
|
93
|
+
const columnInfos = await getColumns(client, rel);
|
|
94
|
+
const knownColumns = columnInfos.map((c) => c.name);
|
|
95
|
+
const selectColumns = args.columns === undefined
|
|
96
|
+
? columnInfos.map((c) => ({ name: c.name, ident: columnIdentFromCatalog(c.name) }))
|
|
97
|
+
: args.columns.map((name) => ({ name, ident: resolveColumn(args.schema, args.table, name, knownColumns) }));
|
|
98
|
+
const primaryKey = await getPrimaryKey(client, rel);
|
|
99
|
+
const pkIdents = (primaryKey?.columns ?? []).map((name) => resolveColumn(args.schema, args.table, name, knownColumns));
|
|
100
|
+
const userSort = (args.orderBy ?? []).map((o) => ({
|
|
101
|
+
name: o.column,
|
|
102
|
+
ident: resolveColumn(args.schema, args.table, o.column, knownColumns),
|
|
103
|
+
direction: o.direction ?? 'ASC',
|
|
104
|
+
...(o.nulls === undefined ? {} : { nulls: o.nulls }),
|
|
105
|
+
}));
|
|
106
|
+
const sortColumns = withTiebreaker(userSort, pkIdents);
|
|
107
|
+
let paramIndex = 1;
|
|
108
|
+
const whereParts = [];
|
|
109
|
+
const params = [];
|
|
110
|
+
for (const pred of args.where ?? []) {
|
|
111
|
+
const ident = resolveColumn(args.schema, args.table, pred.column, knownColumns);
|
|
112
|
+
if (pred.op === 'IS NULL' || pred.op === 'IS NOT NULL') {
|
|
113
|
+
whereParts.push(`${ident} ${pred.op}`);
|
|
114
|
+
}
|
|
115
|
+
else if (pred.op === 'IN' || pred.op === 'NOT IN') {
|
|
116
|
+
const placeholders = pred.value.map(() => `$${paramIndex++}`).join(', ');
|
|
117
|
+
whereParts.push(`${ident} ${pred.op} (${placeholders})`);
|
|
118
|
+
params.push(...pred.value);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
whereParts.push(`${ident} ${pred.op} $${paramIndex++}`);
|
|
122
|
+
params.push(pred.value);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const userWhere = whereParts.length === 0 ? 'TRUE' : whereParts.join(' AND ');
|
|
126
|
+
const cursorValues = args.cursor === undefined ? [] : decodeCursor(args.cursor, shapeHash);
|
|
127
|
+
const keyset = buildKeysetWhere(sortColumns, cursorValues, paramIndex);
|
|
128
|
+
const limitParamIndex = paramIndex + keyset.params.length;
|
|
129
|
+
const sql = `SELECT ${selectColumns.map((c) => c.ident).join(', ')} FROM ${rel.schema}.${rel.name} ` +
|
|
130
|
+
`WHERE (${userWhere}) AND (${keyset.clause}) ORDER BY ${orderByClause(sortColumns)} LIMIT $${limitParamIndex}`;
|
|
131
|
+
// `queryMode: 'extended'` explicitly, never "there are params so it
|
|
132
|
+
// must be extended": `pg` decides that from `values.length > 0`, which
|
|
133
|
+
// is a property of this statement today and not an invariant. Every
|
|
134
|
+
// identifier above is a catalog-resolved `SafeIdent`, so this is
|
|
135
|
+
// defence in depth rather than the fence — but it is the same one
|
|
136
|
+
// line every other call site in this server carries.
|
|
137
|
+
const queryResult = await client.query({
|
|
138
|
+
text: sql,
|
|
139
|
+
values: [...params, ...keyset.params, pageLimit + 1],
|
|
140
|
+
queryMode: 'extended',
|
|
141
|
+
});
|
|
142
|
+
const columns = queryResult.fields.map((f) => ({ name: f.name, dataTypeId: f.dataTypeID }));
|
|
143
|
+
const hasMore = queryResult.rows.length > pageLimit;
|
|
144
|
+
const rows = (hasMore ? queryResult.rows.slice(0, pageLimit) : queryResult.rows);
|
|
145
|
+
const last = rows[rows.length - 1];
|
|
146
|
+
const nextCursor = hasMore && last !== undefined ? encodeCursor(cursorValuesFromRow(last, sortColumns), shapeHash) : undefined;
|
|
147
|
+
return { columns, rows, hasMore, nextCursor };
|
|
148
|
+
}, { readOnly: true, operation: 'select' });
|
|
149
|
+
return formatOutcome({
|
|
150
|
+
kind: 'rows',
|
|
151
|
+
columns: result.columns,
|
|
152
|
+
rows: result.rows,
|
|
153
|
+
rowCount: result.rows.length,
|
|
154
|
+
truncated: result.hasMore,
|
|
155
|
+
...(result.nextCursor === undefined ? {} : { nextCursor: result.nextCursor }),
|
|
156
|
+
}, { limits });
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
return toToolResult(err);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
//# sourceMappingURL=select.js.map
|