This is an automated email from the ASF dual-hosted git repository.
zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new 0dd6e1f9f feat(cmd): list files from a scan plan when the catalog
requires server-side planning (#2260)
0dd6e1f9f is described below
commit 0dd6e1f9f480c1792b01dbe80a83ca7fb8c6d73c
Author: Revanth <[email protected]>
AuthorDate: Thu Oct 22 10:28:08 2026 -0500
feat(cmd): list files from a scan plan when the catalog requires
server-side planning (#2260)
---
cmd/iceberg/files.go | 160 +++++++++++++++++++++
cmd/iceberg/files_test.go | 344 ++++++++++++++++++++++++++++++++++++++++++++++
cmd/iceberg/main.go | 8 +-
cmd/iceberg/output.go | 41 ++++++
website/src/cli.md | 13 ++
5 files changed, 562 insertions(+), 4 deletions(-)
diff --git a/cmd/iceberg/files.go b/cmd/iceberg/files.go
new file mode 100644
index 000000000..adc75a871
--- /dev/null
+++ b/cmd/iceberg/files.go
@@ -0,0 +1,160 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/apache/iceberg-go"
+ "github.com/apache/iceberg-go/catalog"
+ "github.com/apache/iceberg-go/table"
+)
+
+// runFiles lists a table's files. By default it walks the manifests itself,
+// which shows the manifest tree. The files come from a scan plan instead when
+// --scan-planning-mode is remote or auto, or when the catalog requires
+// server-side planning (scan-planning-mode=server), since that directive
+// forbids reading manifests with the table's own credentials.
+func runFiles(ctx context.Context, output Output, cat catalog.Catalog, cmd
*FilesCmd) {
+ mode := table.ScanPlanningMode(cmd.ScanPlanningMode)
+ if err := validateScanPlanningMode(mode); err != nil {
+ output.Error(err)
+ osExit(1)
+
+ return
+ }
+
+ tbl := loadTable(ctx, output, cat, cmd.TableID)
+
+ planned, err := filesFromScanPlan(tbl, mode, cmd.History)
+ if err != nil {
+ output.Error(err)
+ osExit(1)
+
+ return
+ }
+
+ if !planned {
+ output.Files(tbl, cmd.History)
+
+ return
+ }
+
+ tasks, err := planCurrentFiles(ctx, tbl, mode)
+ if err != nil {
+ output.Error(err)
+ osExit(1)
+
+ return
+ }
+
+ output.PlannedFiles(tbl, tasks)
+}
+
+func validateScanPlanningMode(mode table.ScanPlanningMode) error {
+ switch mode {
+ case "", table.ScanPlanningLocal, table.ScanPlanningRemote,
table.ScanPlanningAuto:
+ return nil
+ default:
+ return fmt.Errorf("invalid --scan-planning-mode %q: expected
%q, %q or %q",
+ mode, table.ScanPlanningLocal,
table.ScanPlanningRemote, table.ScanPlanningAuto)
+ }
+}
+
+// filesFromScanPlan reports whether files must list a scan plan rather than
+// walk the manifests. A remote or auto mode that conflicts with a `client`
+// directive is left for PlanFiles to reject.
+func filesFromScanPlan(tbl *table.Table, mode table.ScanPlanningMode, history
bool) (bool, error) {
+ directive, err := tbl.ScanPlanningDirective()
+ if err != nil {
+ return false, err
+ }
+
+ var planned bool
+ switch mode {
+ case "":
+ planned = directive == table.ScanPlanningDirectiveServer
+ case table.ScanPlanningLocal:
+ if directive == table.ScanPlanningDirectiveServer {
+ return false, fmt.Errorf("the catalog requires
server-side scan planning (%s=%s), so files cannot read manifests locally",
+ table.ScanPlanningModeKey,
table.ScanPlanningDirectiveServer)
+ }
+ default:
+ planned = true
+ }
+
+ if planned && history {
+ if mode == "" {
+ return false, fmt.Errorf("--history reads every
snapshot's manifests, which the catalog forbids (%s=%s)",
+ table.ScanPlanningModeKey,
table.ScanPlanningDirectiveServer)
+ }
+
+ return false, errors.New("--history reads every snapshot's
manifests and cannot be combined with --scan-planning-mode " + string(mode))
+ }
+
+ return planned, nil
+}
+
+// planCurrentFiles plans a full scan of the current snapshot. A table without
+// a snapshot has no files, so it is not planned.
+func planCurrentFiles(ctx context.Context, tbl *table.Table, mode
table.ScanPlanningMode) ([]table.FileScanTask, error) {
+ if tbl.CurrentSnapshot() == nil {
+ return nil, nil
+ }
+
+ var opts []table.ScanOption
+ if mode != "" {
+ opts = append(opts, table.WithScanPlanningMode(mode))
+ }
+
+ scan := tbl.Scan(opts...)
+ defer scan.Close()
+
+ return scan.PlanFiles(ctx)
+}
+
+type plannedFile struct {
+ Path string `json:"file-path"`
+ DeleteFiles []string `json:"delete-files,omitempty"`
+}
+
+// plannedDataFiles collapses scan tasks to one entry per data file. A planner
+// may split a large file into several tasks, which share its delete files.
+func plannedDataFiles(tasks []table.FileScanTask) []plannedFile {
+ files := make([]plannedFile, 0, len(tasks))
+ seen := make(map[string]bool, len(tasks))
+ for _, task := range tasks {
+ path := task.File.FilePath()
+ if seen[path] {
+ continue
+ }
+ seen[path] = true
+
+ file := plannedFile{Path: path}
+ for _, deletes := range [][]iceberg.DataFile{task.DeleteFiles,
task.EqualityDeleteFiles, task.DeletionVectorFiles} {
+ for _, df := range deletes {
+ file.DeleteFiles = append(file.DeleteFiles,
df.FilePath())
+ }
+ }
+ files = append(files, file)
+ }
+
+ return files
+}
diff --git a/cmd/iceberg/files_test.go b/cmd/iceberg/files_test.go
new file mode 100644
index 000000000..84c160db4
--- /dev/null
+++ b/cmd/iceberg/files_test.go
@@ -0,0 +1,344 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/apache/iceberg-go"
+ "github.com/apache/iceberg-go/catalog"
+ iceio "github.com/apache/iceberg-go/io"
+ "github.com/apache/iceberg-go/table"
+ "github.com/pterm/pterm"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const filesTestMetadata = `{
+ "format-version": 2,
+ "table-uuid": "c8e7f6d4-03fe-4693-9a96-a0705ddf69c2",
+ "location": "mem://default/table",
+ "last-sequence-number": 1,
+ "last-updated-ms": 1602638573590,
+ "last-column-id": 1,
+ "current-schema-id": 0,
+ "schemas": [
+ {"type": "struct", "schema-id": 0, "fields": [{"id": 1, "name": "id",
"required": true, "type": "int"}]}
+ ],
+ "default-spec-id": 0,
+ "partition-specs": [{"spec-id": 0, "fields": []}],
+ "last-partition-id": 1000,
+ "default-sort-order-id": 0,
+ "sort-orders": [{"order-id": 0, "fields": []}],
+ "properties": {},
+ "current-snapshot-id": 100,
+ "snapshots": [
+ {
+ "snapshot-id": 100,
+ "timestamp-ms": 1602638573590,
+ "sequence-number": 1,
+ "schema-id": 0,
+ "summary": {"operation": "append"}
+ }
+ ],
+ "snapshot-log": [],
+ "refs": {}
+}`
+
+// filesPlanner is a remote scan planner that returns fixed tasks. It also
+// serves as the table's CatalogIO, which is how table.New finds a planner.
+type filesPlanner struct {
+ tasks []table.FileScanTask
+ calls int
+}
+
+func (p *filesPlanner) SupportsRemoteScanPlanning() bool { return true }
+
+func (p *filesPlanner) PlanFiles(context.Context, table.ScanPlanningRequest)
(table.ScanPlanningResult, error) {
+ p.calls++
+
+ return table.ScanPlanningResult{Tasks: p.tasks}, nil
+}
+
+func (p *filesPlanner) LoadTable(context.Context, table.Identifier)
(*table.Table, error) {
+ return nil, errors.New("unexpected LoadTable call")
+}
+
+func (p *filesPlanner) CommitTable(context.Context, table.Identifier,
[]table.Requirement, []table.Update) (table.Metadata, string, error) {
+ return nil, "", errors.New("unexpected CommitTable call")
+}
+
+type filesCatalog struct {
+ catalog.Catalog
+ tbl *table.Table
+}
+
+func (c *filesCatalog) LoadTable(context.Context, table.Identifier)
(*table.Table, error) {
+ return c.tbl, nil
+}
+
+type filesCapture struct {
+ textOutput
+ lastErr error
+}
+
+func (f *filesCapture) Error(err error) {
+ f.lastErr = err
+}
+
+type filesTableOptions struct {
+ directive string
+ noSnapshot bool
+ // readManifests lets the table load its FileIO. Otherwise loading it
fails
+ // the test, which is how the server-directive cases prove files never
+ // walked the manifests. The FileIO is still returned, so a regression
+ // shows up as assertion failures rather than textOutput.Files exiting.
+ readManifests bool
+}
+
+func newFilesTable(t *testing.T, planner *filesPlanner, opts
filesTableOptions) *table.Table {
+ t.Helper()
+
+ metaJSON := filesTestMetadata
+ if opts.noSnapshot {
+ metaJSON = strings.Replace(metaJSON, `"current-snapshot-id":
100,`, "", 1)
+ }
+ meta, err := table.ParseMetadataBytes([]byte(metaJSON))
+ require.NoError(t, err)
+
+ fsF := func(ctx context.Context) (iceio.IO, error) {
+ if !opts.readManifests {
+ t.Error("files read the table's manifests locally")
+ }
+
+ return iceio.LoadFS(ctx, nil, "mem://default/table")
+ }
+
+ return table.New([]string{"db", "events"}, meta, "", fsF, planner,
+ table.WithScanPlanningDirective(opts.directive, opts.directive
!= ""))
+}
+
+func filesTestDataFile(t *testing.T, content iceberg.ManifestEntryContent,
path string) iceberg.DataFile {
+ t.Helper()
+
+ builder, err := iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec,
content, path,
+ iceberg.ParquetFile, nil, nil, nil, 10, 100)
+ require.NoError(t, err)
+
+ return builder.Build()
+}
+
+func filesTestTasks(t *testing.T) []table.FileScanTask {
+ t.Helper()
+
+ first := filesTestDataFile(t, iceberg.EntryContentData,
"mem://default/table/data/a.parquet")
+ posDelete := filesTestDataFile(t, iceberg.EntryContentPosDeletes,
"mem://default/table/data/a-deletes.parquet")
+ second := filesTestDataFile(t, iceberg.EntryContentData,
"mem://default/table/data/b.parquet")
+
+ return []table.FileScanTask{
+ {File: first, DeleteFiles: []iceberg.DataFile{posDelete},
Start: 0, Length: 50},
+ {File: first, DeleteFiles: []iceberg.DataFile{posDelete},
Start: 50, Length: 50},
+ {File: second, Length: 100},
+ }
+}
+
+func runFilesCapture(t *testing.T, tbl *table.Table, cmd *FilesCmd) (out
string, capture *filesCapture, exitCode int) {
+ t.Helper()
+
+ var buf bytes.Buffer
+ pterm.SetDefaultOutput(&buf)
+ pterm.DisableColor()
+ t.Cleanup(func() { pterm.SetDefaultOutput(os.Stdout) })
+
+ capture = &filesCapture{}
+ exitCode = captureBranchTagExit(func() {
+ runFiles(context.Background(), capture, &filesCatalog{tbl:
tbl}, cmd)
+ })
+
+ return buf.String(), capture, exitCode
+}
+
+func TestRunFilesListsScanPlan(t *testing.T) {
+ tests := []struct {
+ name string
+ directive string
+ mode string
+ }{
+ {name: "server directive", directive: "server"},
+ {name: "server directive with remote mode", directive:
"server", mode: "remote"},
+ {name: "remote mode", mode: "remote"},
+ {name: "auto mode", mode: "auto"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ planner := &filesPlanner{tasks: filesTestTasks(t)}
+ tbl := newFilesTable(t, planner,
filesTableOptions{directive: tt.directive})
+
+ out, capture, exitCode := runFilesCapture(t, tbl,
&FilesCmd{TableID: "db.events", ScanPlanningMode: tt.mode})
+
+ require.NoError(t, capture.lastErr)
+ assert.Equal(t, 0, exitCode)
+ assert.Equal(t, 1, planner.calls)
+ assert.Contains(t, out, "Planned files: db.events")
+ assert.Contains(t, out, "Snapshot 100, schema 0")
+ assert.Equal(t, 1, strings.Count(out, "Datafile:
mem://default/table/data/a.parquet"),
+ "a file split into several tasks is listed
once")
+ assert.Contains(t, out, "Deletefile:
mem://default/table/data/a-deletes.parquet")
+ assert.Contains(t, out, "Datafile:
mem://default/table/data/b.parquet")
+ })
+ }
+}
+
+func TestRunFilesWalksManifestsLocally(t *testing.T) {
+ tests := []struct {
+ name string
+ directive string
+ mode string
+ }{
+ {name: "no directive"},
+ {name: "client directive", directive: "client"},
+ {name: "local mode", mode: "local"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ planner := &filesPlanner{}
+ tbl := newFilesTable(t, planner,
filesTableOptions{directive: tt.directive, readManifests: true})
+
+ out, capture, exitCode := runFilesCapture(t, tbl,
&FilesCmd{TableID: "db.events", ScanPlanningMode: tt.mode})
+
+ require.NoError(t, capture.lastErr)
+ assert.Equal(t, 0, exitCode)
+ assert.Zero(t, planner.calls)
+ assert.Contains(t, out, "Snapshots: db.events")
+ assert.Contains(t, out, "Snapshot 100, schema 0")
+ })
+ }
+}
+
+func TestRunFilesRejects(t *testing.T) {
+ tests := []struct {
+ name string
+ directive string
+ cmd FilesCmd
+ wantErr string
+ }{
+ {
+ name: "history under server directive",
+ directive: "server",
+ cmd: FilesCmd{History: true},
+ wantErr: "--history reads every snapshot's manifests,
which the catalog forbids (scan-planning-mode=server)",
+ },
+ {
+ name: "history with remote mode",
+ cmd: FilesCmd{History: true, ScanPlanningMode:
"remote"},
+ wantErr: "cannot be combined with --scan-planning-mode
remote",
+ },
+ {
+ name: "local mode under server directive",
+ directive: "server",
+ cmd: FilesCmd{ScanPlanningMode: "local"},
+ wantErr: "the catalog requires server-side scan
planning (scan-planning-mode=server)",
+ },
+ {
+ name: "remote mode under client directive",
+ directive: "client",
+ cmd: FilesCmd{ScanPlanningMode: "remote"},
+ wantErr: "the catalog requires client-side scan
planning",
+ },
+ {
+ name: "unrecognized directive",
+ directive: "sometimes",
+ wantErr: `unrecognized scan-planning-mode
"sometimes"`,
+ },
+ {
+ name: "unknown mode",
+ cmd: FilesCmd{ScanPlanningMode: "server"},
+ wantErr: `invalid --scan-planning-mode "server"`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ planner := &filesPlanner{tasks: filesTestTasks(t)}
+ tbl := newFilesTable(t, planner,
filesTableOptions{directive: tt.directive})
+ tt.cmd.TableID = "db.events"
+
+ _, capture, exitCode := runFilesCapture(t, tbl, &tt.cmd)
+
+ require.Error(t, capture.lastErr)
+ assert.Contains(t, capture.lastErr.Error(), tt.wantErr)
+ assert.Equal(t, 1, exitCode)
+ assert.Zero(t, planner.calls)
+ })
+ }
+}
+
+func TestRunFilesSkipsPlanningWithoutSnapshot(t *testing.T) {
+ planner := &filesPlanner{tasks: filesTestTasks(t)}
+ tbl := newFilesTable(t, planner, filesTableOptions{directive: "server",
noSnapshot: true})
+
+ out, capture, exitCode := runFilesCapture(t, tbl, &FilesCmd{TableID:
"db.events"})
+
+ require.NoError(t, capture.lastErr)
+ assert.Equal(t, 0, exitCode)
+ assert.Zero(t, planner.calls)
+ assert.Contains(t, out, "Planned files: db.events")
+ assert.NotContains(t, out, "Datafile:")
+}
+
+func TestJSONOutputPlannedFiles(t *testing.T) {
+ oldStdout := os.Stdout
+ r, w, err := os.Pipe()
+ require.NoError(t, err)
+ os.Stdout = w
+ defer func() {
+ os.Stdout = oldStdout
+ }()
+
+ tbl := newFilesTable(t, &filesPlanner{}, filesTableOptions{})
+
+ jsonOutput{}.PlannedFiles(tbl, filesTestTasks(t))
+
+ w.Close()
+ var buf bytes.Buffer
+ _, _ = buf.ReadFrom(r)
+
+ var got struct {
+ Snapshot struct {
+ SnapshotID int64 `json:"snapshot-id"`
+ } `json:"snapshot"`
+ Files []plannedFile `json:"files"`
+ }
+ require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
+ assert.Equal(t, int64(100), got.Snapshot.SnapshotID)
+ assert.Equal(t, []plannedFile{
+ {
+ Path: "mem://default/table/data/a.parquet",
+ DeleteFiles:
[]string{"mem://default/table/data/a-deletes.parquet"},
+ },
+ {Path: "mem://default/table/data/b.parquet"},
+ }, got.Files)
+}
diff --git a/cmd/iceberg/main.go b/cmd/iceberg/main.go
index 760dfe934..ae6349f9d 100644
--- a/cmd/iceberg/main.go
+++ b/cmd/iceberg/main.go
@@ -82,8 +82,9 @@ type LocationCmd struct {
}
type FilesCmd struct {
- TableID string `arg:"positional,required" help:"full path to a table"`
- History bool `arg:"--history" help:"show all snapshots"`
+ TableID string `arg:"positional,required" help:"full path to a
table"`
+ History bool `arg:"--history" help:"show all snapshots"`
+ ScanPlanningMode string `arg:"--scan-planning-mode" help:"local walks
the manifests; remote or auto lists a scan plan's files (default: follow the
catalog's scan-planning-mode)"`
}
type RenameCmd struct {
@@ -336,8 +337,7 @@ func main() {
case args.Drop != nil:
runDrop(ctx, output, cat, args.Drop)
case args.Files != nil:
- tbl := loadTable(ctx, output, cat, args.Files.TableID)
- output.Files(tbl, args.Files.History)
+ runFiles(ctx, output, cat, args.Files)
case args.Rename != nil:
runRename(ctx, output, cat, args.Rename)
case args.Properties != nil:
diff --git a/cmd/iceberg/output.go b/cmd/iceberg/output.go
index 8b3095638..2b19987e7 100644
--- a/cmd/iceberg/output.go
+++ b/cmd/iceberg/output.go
@@ -38,6 +38,7 @@ type Output interface {
Identifiers([]table.Identifier)
DescribeTable(*table.Table)
Files(tbl *table.Table, history bool)
+ PlannedFiles(tbl *table.Table, tasks []table.FileScanTask)
DescribeProperties(iceberg.Properties)
Info(tbl *table.Table)
Snapshots(tbl *table.Table)
@@ -192,6 +193,31 @@ func (t textOutput) Files(tbl *table.Table, history bool) {
pterm.DefaultTree.WithRoot(node).Render()
}
+func (t textOutput) PlannedFiles(tbl *table.Table, tasks []table.FileScanTask)
{
+ fileTree := pterm.LeveledList{}
+ if snap := tbl.CurrentSnapshot(); snap != nil {
+ fileTree = append(fileTree, pterm.LeveledListItem{
+ Level: 0,
+ Text: fmt.Sprintf("Snapshot %d, schema %s",
snap.SnapshotID, snapshotSchemaText(snap.SchemaID)),
+ })
+
+ for _, file := range plannedDataFiles(tasks) {
+ fileTree = append(fileTree, pterm.LeveledListItem{
+ Level: 1, Text: "Datafile: " + file.Path,
+ })
+ for _, del := range file.DeleteFiles {
+ fileTree = append(fileTree,
pterm.LeveledListItem{
+ Level: 2, Text: "Deletefile: " + del,
+ })
+ }
+ }
+ }
+
+ node := putils.TreeFromLeveledList(fileTree)
+ node.Text = "Planned files: " + strings.Join(tbl.Identifier(), ".")
+ pterm.DefaultTree.WithRoot(node).Render()
+}
+
func (textOutput) DescribeProperties(props iceberg.Properties) {
data := pterm.TableData{[]string{"Key", "Value"}}
for k, v := range props {
@@ -312,6 +338,21 @@ func (j jsonOutput) Files(tbl *table.Table, history bool) {
}
}
+func (j jsonOutput) PlannedFiles(tbl *table.Table, tasks []table.FileScanTask)
{
+ type dataType struct {
+ Snapshot *table.Snapshot `json:"snapshot"`
+ Files []plannedFile `json:"files"`
+ }
+
+ data := dataType{
+ Snapshot: tbl.CurrentSnapshot(),
+ Files: plannedDataFiles(tasks),
+ }
+ if err := json.NewEncoder(os.Stdout).Encode(data); err != nil {
+ j.Error(err)
+ }
+}
+
func (j jsonOutput) DescribeProperties(props iceberg.Properties) {
if err := json.NewEncoder(os.Stdout).Encode(props); err != nil {
j.Error(err)
diff --git a/website/src/cli.md b/website/src/cli.md
index c5415e69e..ad8ee2986 100644
--- a/website/src/cli.md
+++ b/website/src/cli.md
@@ -175,6 +175,19 @@ iceberg partition-stats --snapshot-id 7234981023498
my_db.events
iceberg partition-stats --all my_db.events # all snapshots
```
+### `files` — data files
+
+```shell
+iceberg files my_db.events # current snapshot
+iceberg files --history my_db.events # all snapshots
+iceberg files --scan-planning-mode remote my_db.events # from a server-side
scan plan
+```
+By default `files` reads the manifest list and manifests itself and prints
them as a tree: snapshot, manifest, then each manifest entry.
+
+With `--scan-planning-mode remote` or `auto`, or when a REST catalog sets
`scan-planning-mode=server` for the table, `files` plans a scan of the current
snapshot instead and lists the data files it returns, each with its delete
files. A scan plan carries no manifests, so the tree has no manifest level, and
it only shows live files. `--history` needs every snapshot's manifests and
fails in this mode, as does `--scan-planning-mode local` on a `server` table.
+
+`compact` plans its input with a table scan, so it follows the catalog's
`scan-planning-mode` as well. Every other command reads table metadata directly.
+
### `schema --show-defaults`
```shell