@chaprola/mcp-server 1.6.3 → 1.6.4

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/dist/index.js CHANGED
@@ -102,7 +102,7 @@ const server = new McpServer({
102
102
  - **Run programs:** chaprola_run (single execution), chaprola_run_each (per-record batch), chaprola_report (published reports)
103
103
  - **Email:** chaprola_email_send, chaprola_email_inbox, chaprola_email_read
104
104
  - **Web:** chaprola_search (Brave API), chaprola_fetch (URL → markdown)
105
- - **Schema:** chaprola_format (inspect fields), chaprola_alter (add/widen/rename/drop fields)
105
+ - **Schema:** chaprola_format (inspect fields), chaprola_alter (add/widen/rename/drop fields — NON-DESTRUCTIVE. Use this, NOT chaprola_import, to modify schemas on existing data.)
106
106
  - **Export:** chaprola_export (JSON or FHIR — full round-trip: FHIR in, process, FHIR out)
107
107
  - **Schedule:** chaprola_schedule (cron jobs for any endpoint)
108
108
 
@@ -174,6 +174,9 @@ server.resource("ref-import", "chaprola://ref/import", { description: "Import, e
174
174
  server.resource("ref-query", "chaprola://ref/query", { description: "Query, sort, index, merge, record CRUD — data operations", mimeType: "text/markdown" }, async () => ({
175
175
  contents: [{ uri: "chaprola://ref/query", mimeType: "text/markdown", text: readRef("ref-query.md") }],
176
176
  }));
177
+ server.resource("ref-schema", "chaprola://ref/schema", { description: "Schema operations — /format (inspect), /alter (widen/rename/add/drop fields). CRITICAL: Use /alter for schema changes, NOT /import.", mimeType: "text/markdown" }, async () => ({
178
+ contents: [{ uri: "chaprola://ref/schema", mimeType: "text/markdown", text: readRef("ref-schema.md") }],
179
+ }));
177
180
  server.resource("ref-pivot", "chaprola://ref/pivot", { description: "Pivot tables (GROUP BY) — row, column, aggregate functions", mimeType: "text/markdown" }, async () => ({
178
181
  contents: [{ uri: "chaprola://ref/pivot", mimeType: "text/markdown", text: readRef("ref-pivot.md") }],
179
182
  }));
@@ -277,8 +280,9 @@ server.tool("chaprola_report", "Run a published program and return output. No au
277
280
  project: z.string().describe("Project containing the program"),
278
281
  name: z.string().describe("Name of the published .PR file"),
279
282
  token: z.string().optional().describe("Action token (act_...) for writable reports. Required to persist WRITE/DELETE/QUERY operations. Provided when program was published with writable=true."),
280
- params: z.record(z.union([z.string(), z.number()])).optional().describe("Parameters to inject before execution. Named params (e.g., {deck: \"kanji\", level: 3}) are read in programs via PARAM.name. Legacy R-variables (r1-r20) also supported. Use chaprola_report_params to discover what params a report accepts."),
281
- }, async ({ userid, project, name, token, params }) => {
283
+ params: z.string().optional().describe("JSON object of named params, e.g. {\"deck\": \"kanji\", \"level\": 3}. Named params are read in programs via PARAM.name. Legacy R-variables (r1-r20) also supported. Use chaprola_report_params to discover what params a report accepts."),
284
+ }, async ({ userid, project, name, token, params: paramsStr }) => {
285
+ const params = typeof paramsStr === 'string' ? JSON.parse(paramsStr) : paramsStr;
282
286
  const urlParams = new URLSearchParams();
283
287
  urlParams.set("userid", userid);
284
288
  urlParams.set("project", project);
@@ -330,13 +334,14 @@ server.tool("chaprola_baa_status", "Check whether the authenticated user has sig
330
334
  return textResult(res);
331
335
  });
332
336
  // --- Import ---
333
- server.tool("chaprola_import", "Import JSON data into Chaprola format files (.F + .DA). Sign BAA first if handling PHI", {
337
+ server.tool("chaprola_import", "Import JSON data into Chaprola format files (.F + .DA). DESTRUCTIVE: This REPLACES both the format (.F) and data (.DA) files if they already exist. All existing data will be lost. Use chaprola_alter to modify field widths/schema on existing data. Use chaprola_import only for new data or when replacing entire datasets. Sign BAA first if handling PHI", {
334
338
  project: z.string().describe("Project name"),
335
339
  name: z.string().describe("File name (without extension)"),
336
- data: z.array(z.record(z.any())).describe("Array of flat JSON objects to import"),
340
+ data: z.string().describe("JSON array of record objects to import"),
337
341
  format: z.enum(["json", "fhir"]).optional().describe("Data format: json (default) or fhir"),
338
342
  expires_in_days: z.number().optional().describe("Days until data expires (default: 90)"),
339
- }, async ({ project, name, data, format, expires_in_days }) => withBaaCheck(async () => {
343
+ }, async ({ project, name, data: dataStr, format, expires_in_days }) => withBaaCheck(async () => {
344
+ const data = typeof dataStr === 'string' ? JSON.parse(dataStr) : dataStr;
340
345
  const { username } = getCredentials();
341
346
  const body = { userid: username, project, name, data };
342
347
  if (format)
@@ -346,7 +351,7 @@ server.tool("chaprola_import", "Import JSON data into Chaprola format files (.F
346
351
  const res = await authedFetch("/import", body);
347
352
  return textResult(res);
348
353
  }));
349
- server.tool("chaprola_import_url", "Get a presigned S3 upload URL for large files (bypasses 6MB API Gateway limit)", {
354
+ server.tool("chaprola_import_url", "Get a presigned S3 upload URL for large files (bypasses 6MB API Gateway limit). DESTRUCTIVE: The subsequent chaprola_import_process will replace existing data. Use chaprola_alter to modify schemas on existing data.", {
350
355
  project: z.string().describe("Project name"),
351
356
  name: z.string().describe("File name (without extension)"),
352
357
  }, async ({ project, name }) => withBaaCheck(async () => {
@@ -354,7 +359,7 @@ server.tool("chaprola_import_url", "Get a presigned S3 upload URL for large file
354
359
  const res = await authedFetch("/import-url", { userid: username, project, name });
355
360
  return textResult(res);
356
361
  }));
357
- server.tool("chaprola_import_process", "Process a file previously uploaded to S3 via presigned URL. Generates .F + .DA files", {
362
+ server.tool("chaprola_import_process", "Process a file previously uploaded to S3 via presigned URL. Generates .F + .DA files. DESTRUCTIVE: Replaces existing data if the file already exists. Use chaprola_alter to modify schemas on existing data.", {
358
363
  project: z.string().describe("Project name"),
359
364
  name: z.string().describe("File name (without extension)"),
360
365
  format: z.enum(["json", "fhir"]).optional().describe("Data format: json (default) or fhir"),
@@ -366,10 +371,10 @@ server.tool("chaprola_import_process", "Process a file previously uploaded to S3
366
371
  const res = await authedFetch("/import-process", body);
367
372
  return textResult(res);
368
373
  }));
369
- server.tool("chaprola_import_download", "Import data directly from a public URL (CSV, TSV, JSON, NDJSON, Parquet, Excel). Optional AI-powered schema inference", {
374
+ server.tool("chaprola_import_download", "Import data directly from a public URL (CSV, TSV, JSON, NDJSON, Parquet, Excel). Optional AI-powered schema inference. DESTRUCTIVE: Replaces existing data if the file already exists. Use chaprola_alter to modify schemas on existing data.", {
370
375
  project: z.string().describe("Project name"),
371
376
  name: z.string().describe("Output file name (without extension)"),
372
- url: z.string().url().describe("Public URL to download (http/https only)"),
377
+ url: z.string().describe("Public URL to download (http/https only)"),
373
378
  instructions: z.string().optional().describe("Natural language instructions for AI-powered field selection and transforms"),
374
379
  max_rows: z.number().optional().describe("Maximum rows to import (default: 5,000,000)"),
375
380
  }, async ({ project, name, url, instructions, max_rows }) => withBaaCheck(async () => {
@@ -539,16 +544,22 @@ server.tool("chaprola_download", "Get a presigned S3 URL to download any file yo
539
544
  server.tool("chaprola_query", "SQL-free data query with WHERE, SELECT, aggregation, ORDER BY, JOIN, pivot, and Mercury scoring", {
540
545
  project: z.string().describe("Project name"),
541
546
  file: z.string().describe("Data file to query"),
542
- where: z.record(z.any()).optional().describe("Filter: {field, op, value}. Ops: eq, ne, gt, ge, lt, le, between, contains, starts_with"),
547
+ where: z.string().optional().describe("JSON object of filter conditions, e.g. {\"field\": \"status\", \"op\": \"eq\", \"value\": \"active\"}. Ops: eq, ne, gt, ge, lt, le, between, contains, starts_with"),
543
548
  select: z.array(z.string()).optional().describe("Fields to include in output"),
544
- aggregate: z.array(z.record(z.any())).optional().describe("Aggregation: [{field, func}]. Funcs: count, sum, avg, min, max, stddev"),
545
- order_by: z.array(z.record(z.any())).optional().describe("Sort: [{field, dir}]"),
549
+ aggregate: z.string().optional().describe("JSON array of aggregation specs, e.g. [{\"field\": \"amount\", \"func\": \"sum\"}]. Funcs: count, sum, avg, min, max, stddev"),
550
+ order_by: z.string().optional().describe("JSON array of sort specs, e.g. [{\"field\": \"name\", \"dir\": \"asc\"}]"),
546
551
  limit: z.number().optional().describe("Max results to return"),
547
552
  offset: z.number().optional().describe("Skip this many results"),
548
- join: z.record(z.any()).optional().describe("Join: {file, on, type, method}"),
549
- pivot: z.record(z.any()).optional().describe("Pivot: {row, column, values, totals, grand_total}"),
550
- mercury: z.record(z.any()).optional().describe("Mercury scoring: {fields: [{field, target, weight}]}"),
551
- }, async ({ project, file, where, select, aggregate, order_by, limit, offset, join, pivot, mercury }) => withBaaCheck(async () => {
553
+ join: z.string().optional().describe("JSON object of join config, e.g. {\"file\": \"other\", \"on\": \"id\", \"type\": \"inner\"}"),
554
+ pivot: z.string().optional().describe("JSON object of pivot config, e.g. {\"row\": \"category\", \"column\": \"month\", \"values\": \"sales\"}"),
555
+ mercury: z.string().optional().describe("JSON object of mercury scoring config, e.g. {\"fields\": [{\"field\": \"score\", \"target\": 100, \"weight\": 1.0}]}"),
556
+ }, async ({ project, file, where: whereStr, select, aggregate: aggregateStr, order_by: orderByStr, limit, offset, join: joinStr, pivot: pivotStr, mercury: mercuryStr }) => withBaaCheck(async () => {
557
+ const where = typeof whereStr === 'string' ? JSON.parse(whereStr) : whereStr;
558
+ const aggregate = typeof aggregateStr === 'string' ? JSON.parse(aggregateStr) : aggregateStr;
559
+ const order_by = typeof orderByStr === 'string' ? JSON.parse(orderByStr) : orderByStr;
560
+ const join = typeof joinStr === 'string' ? JSON.parse(joinStr) : joinStr;
561
+ const pivot = typeof pivotStr === 'string' ? JSON.parse(pivotStr) : pivotStr;
562
+ const mercury = typeof mercuryStr === 'string' ? JSON.parse(mercuryStr) : mercuryStr;
552
563
  const { username } = getCredentials();
553
564
  const body = { userid: username, project, file };
554
565
  if (where)
@@ -617,7 +628,7 @@ server.tool("chaprola_format", "Inspect a data file's schema — returns field n
617
628
  const res = await authedFetch("/format", { userid: username, project, name });
618
629
  return textResult(res);
619
630
  }));
620
- server.tool("chaprola_alter", "Modify a data file's schema: widen/narrow/rename fields, add new fields, drop fields. Transforms existing data to match the new schema.", {
631
+ server.tool("chaprola_alter", "Modify a data file's schema: widen/narrow/rename fields, add new fields, drop fields. NON-DESTRUCTIVE: Transforms existing data to match the new schema. This is the ONLY safe way to change field widths or schema on existing data files. Unlike chaprola_import which replaces all data, chaprola_alter preserves and reformats existing records.", {
621
632
  project: z.string().describe("Project name"),
622
633
  name: z.string().describe("Data file name (without extension)"),
623
634
  alter: z.array(z.object({
@@ -756,7 +767,7 @@ server.tool("chaprola_search", "Search the web via Brave Search API. Returns tit
756
767
  }));
757
768
  // --- Fetch ---
758
769
  server.tool("chaprola_fetch", "Fetch any URL and return clean content. HTML pages converted to markdown. SSRF-protected. Rate limit: 20/day per user", {
759
- url: z.string().url().describe("URL to fetch (http:// or https://)"),
770
+ url: z.string().describe("URL to fetch (http:// or https://)"),
760
771
  format: z.enum(["markdown", "text", "html", "json"]).optional().describe("Output format (default: markdown)"),
761
772
  max_length: z.number().optional().describe("Max output characters (default: 50000, max: 200000)"),
762
773
  }, async ({ url, format, max_length }) => withBaaCheck(async () => {
@@ -773,9 +784,10 @@ server.tool("chaprola_schedule", "Create a scheduled job that runs a Chaprola en
773
784
  name: z.string().describe("Unique name for this schedule (alphanumeric + hyphens/underscores)"),
774
785
  cron: z.string().describe("Standard 5-field cron expression (min hour day month weekday). Minimum interval: 15 minutes"),
775
786
  endpoint: z.enum(["/import-download", "/run", "/export-report", "/search", "/fetch", "/query", "/email/send", "/export", "/report", "/list"]).describe("Target endpoint to call"),
776
- body: z.record(z.any()).describe("Request body for the target endpoint. userid is injected automatically"),
787
+ body: z.string().describe("JSON object of the schedule request body. userid is injected automatically"),
777
788
  skip_if_unchanged: z.boolean().optional().describe("Skip when response matches previous run (SHA-256 hash). Default: false"),
778
- }, async ({ name, cron, endpoint, body, skip_if_unchanged }) => withBaaCheck(async () => {
789
+ }, async ({ name, cron, endpoint, body: bodyStr, skip_if_unchanged }) => withBaaCheck(async () => {
790
+ const body = typeof bodyStr === 'string' ? JSON.parse(bodyStr) : bodyStr;
779
791
  const reqBody = { name, cron, endpoint, body };
780
792
  if (skip_if_unchanged !== undefined)
781
793
  reqBody.skip_if_unchanged = skip_if_unchanged;
@@ -796,8 +808,9 @@ server.tool("chaprola_schedule_delete", "Delete a scheduled job by name", {
796
808
  server.tool("chaprola_insert_record", "Insert a new record into a data file's merge file (.MRG). The record appears at the end of the file until consolidation.", {
797
809
  project: z.string().describe("Project name"),
798
810
  file: z.string().describe("Data file name (without extension)"),
799
- record: z.record(z.string()).describe("Field name value pairs. Unspecified fields default to blanks."),
800
- }, async ({ project, file, record }) => withBaaCheck(async () => {
811
+ record: z.string().describe("JSON object of the record to insert, e.g. {\"name\": \"foo\", \"status\": \"active\"}. Unspecified fields default to blanks."),
812
+ }, async ({ project, file, record: recordStr }) => withBaaCheck(async () => {
813
+ const record = typeof recordStr === 'string' ? JSON.parse(recordStr) : recordStr;
801
814
  const { username } = getCredentials();
802
815
  const res = await authedFetch("/insert-record", { userid: username, project, file, record });
803
816
  return textResult(res);
@@ -805,9 +818,11 @@ server.tool("chaprola_insert_record", "Insert a new record into a data file's me
805
818
  server.tool("chaprola_update_record", "Update fields in a single record matched by a where clause. If no sort-key changes, updates in place; otherwise marks old record ignored and appends to merge file.", {
806
819
  project: z.string().describe("Project name"),
807
820
  file: z.string().describe("Data file name (without extension)"),
808
- where: z.record(z.string()).describe("Field name value pairs to identify exactly one record"),
809
- set: z.record(z.string()).describe("Field name new value pairs to update"),
810
- }, async ({ project, file, where: whereClause, set }) => withBaaCheck(async () => {
821
+ where: z.string().describe("JSON object of filter conditions for which records to update, e.g. {\"id\": \"123\"}"),
822
+ set: z.string().describe("JSON object of fields to update, e.g. {\"status\": \"done\"}"),
823
+ }, async ({ project, file, where: whereStr, set: setStr }) => withBaaCheck(async () => {
824
+ const whereClause = typeof whereStr === 'string' ? JSON.parse(whereStr) : whereStr;
825
+ const set = typeof setStr === 'string' ? JSON.parse(setStr) : setStr;
811
826
  const { username } = getCredentials();
812
827
  const res = await authedFetch("/update-record", { userid: username, project, file, where: whereClause, set });
813
828
  return textResult(res);
@@ -815,8 +830,9 @@ server.tool("chaprola_update_record", "Update fields in a single record matched
815
830
  server.tool("chaprola_delete_record", "Delete a single record matched by a where clause. Marks the record as ignored (.IGN). Physically removed on consolidation.", {
816
831
  project: z.string().describe("Project name"),
817
832
  file: z.string().describe("Data file name (without extension)"),
818
- where: z.record(z.string()).describe("Field name value pairs to identify exactly one record"),
819
- }, async ({ project, file, where: whereClause }) => withBaaCheck(async () => {
833
+ where: z.string().describe("JSON object of filter conditions for which records to delete, e.g. {\"id\": \"123\"}"),
834
+ }, async ({ project, file, where: whereStr }) => withBaaCheck(async () => {
835
+ const whereClause = typeof whereStr === 'string' ? JSON.parse(whereStr) : whereStr;
820
836
  const { username } = getCredentials();
821
837
  const res = await authedFetch("/delete-record", { userid: username, project, file, where: whereClause });
822
838
  return textResult(res);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chaprola/mcp-server",
3
- "version": "1.6.3",
4
- "description": "MCP server for Chaprola — agent-first data platform. Gives AI agents 46 tools for structured data storage, record CRUD, querying, schema inspection, web search, URL fetching, scheduled jobs, and execution via plain HTTP.",
3
+ "version": "1.6.4",
4
+ "description": "MCP server for Chaprola — agent-first data platform. Gives AI agents 47 tools for structured data storage, record CRUD, querying, schema inspection, web search, URL fetching, scheduled jobs, and execution via plain HTTP.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -11,6 +11,7 @@
11
11
  - **Statement numbers are labels, not line numbers.** Only number GOTO/CALL targets.
12
12
 
13
13
  ## API
14
+ - **NEVER use `/import` to change field widths.** `/import` REPLACES existing data. Use `/alter` to widen/narrow fields while preserving data. See `chaprola://ref/schema`.
14
15
  - **userid must match authenticated user.** 403 on mismatch.
15
16
  - **Login invalidates the old key.** Save the new one immediately.
16
17
  - **Async for large datasets.** `/run` with `async: true` for >100K records (API Gateway 30s timeout).
@@ -10,6 +10,14 @@ POST /import {userid, project, name: "STAFF", data: [{"name": "Alice", "salary":
10
10
 
11
11
  Field widths auto-sized from longest value. Default expiry: 90 days. Override with `expires_in_days`.
12
12
 
13
+ ### ⚠️ DESTRUCTIVE WARNING
14
+
15
+ **`/import` REPLACES both the format (.F) and data (.DA) files if they already exist. All existing data will be lost.**
16
+
17
+ - **Use `/import` ONLY for:** Creating brand new data files or intentionally replacing entire datasets
18
+ - **DO NOT use `/import` to:** Change field widths or modify schema on existing data
19
+ - **Use `/alter` instead** to modify field widths/schema while preserving existing data (see `chaprola://ref/schema`)
20
+
13
21
  ## Large File Upload (presigned URL)
14
22
  ```bash
15
23
  POST /import-url {userid, project, name} → {upload_url, staging_key}
@@ -17,11 +25,15 @@ POST /import-url {userid, project, name} → {upload_url, staging_key}
17
25
  POST /import-process {userid, project, name, staging_key} → same as /import
18
26
  ```
19
27
 
28
+ **WARNING:** `/import-process` is also DESTRUCTIVE and replaces existing data. Use `/alter` for schema changes.
29
+
20
30
  ## POST /import-download
21
31
  `{userid, project, name, url, instructions?, max_rows?}`
22
32
  Imports directly from URL. Supports: CSV, TSV, JSON, NDJSON, Parquet, Excel (.xlsx/.xls).
23
33
  Optional `instructions` for AI schema inference. Max 1M records.
24
34
 
35
+ **WARNING:** `/import-download` is also DESTRUCTIVE and replaces existing data. Use `/alter` for schema changes.
36
+
25
37
  ## POST /export
26
38
  `{userid, project, name, format?}` → `{data: [...records]}`
27
39
  Optional `format: "fhir"` for FHIR JSON reconstruction.
@@ -0,0 +1,166 @@
1
+ # Schema Inspection & Modification
2
+
3
+ ## POST /format
4
+ Inspect a data file's schema — returns field names, positions, lengths, types, and PHI flags.
5
+
6
+ ```bash
7
+ POST /format {userid, project, name: "STAFF"}
8
+ ```
9
+
10
+ Returns:
11
+ ```json
12
+ {
13
+ "format_file": "s3://chaprola-2026/userid/project/format/STAFF.F",
14
+ "fields": [
15
+ {"name": "name", "position": 1, "length": 50, "type": "text", "phi": false},
16
+ {"name": "salary", "position": 51, "length": 10, "type": "numeric", "phi": false}
17
+ ],
18
+ "record_length": 60
19
+ }
20
+ ```
21
+
22
+ Use this to inspect the current schema before making changes with `/alter`.
23
+
24
+ ---
25
+
26
+ ## POST /alter
27
+ **NON-DESTRUCTIVE schema modification.** Modifies field widths, renames fields, adds new fields, or drops fields. Existing data is preserved and reformatted to match the new schema.
28
+
29
+ ### ⚠️ CRITICAL: Use /alter for schema changes, NOT /import
30
+
31
+ **DO NOT use `/import` to change field widths or schema on existing data files.** `/import` REPLACES both the format (.F) and data (.DA) files, destroying all existing data.
32
+
33
+ **Use `/alter` when you need to:**
34
+ - Widen or narrow field widths
35
+ - Rename fields
36
+ - Add new fields to existing data
37
+ - Drop unused fields
38
+ - Change field types
39
+
40
+ **Use `/import` only when:**
41
+ - Creating a brand new data file
42
+ - Intentionally replacing an entire dataset
43
+
44
+ ---
45
+
46
+ ## /alter Request Format
47
+
48
+ ```json
49
+ {
50
+ "userid": "...",
51
+ "project": "...",
52
+ "name": "STAFF",
53
+ "alter": [
54
+ {"field": "name", "width": 100}, // widen from 50 to 100
55
+ {"field": "dept", "rename": "department"}
56
+ ],
57
+ "add": [
58
+ {"name": "email", "width": 80, "type": "text", "after": "name"}
59
+ ],
60
+ "drop": ["old_field"],
61
+ "output": "STAFF_V2" // optional: create new file instead of in-place
62
+ }
63
+ ```
64
+
65
+ ### Parameters
66
+
67
+ **`alter`** (optional): Array of field modifications
68
+ - `field` (required): Field name to modify
69
+ - `width` (optional): New width (can widen or narrow)
70
+ - `rename` (optional): New field name
71
+ - `type` (optional): Change type (`"text"` or `"numeric"`)
72
+
73
+ **`add`** (optional): Array of new fields to add
74
+ - `name` (required): New field name
75
+ - `width` (required): Field width
76
+ - `type` (optional): `"text"` (default) or `"numeric"`
77
+ - `after` (optional): Insert after this field (default: end of record)
78
+
79
+ **`drop`** (optional): Array of field names to remove
80
+
81
+ **`output`** (optional): Output file name. If not specified, modifies in-place.
82
+
83
+ ---
84
+
85
+ ## Examples
86
+
87
+ ### Widen a field (most common case)
88
+ ```bash
89
+ # Widen 'description' field from 100 to 500 characters
90
+ POST /alter {
91
+ userid, project, name: "ITEMS",
92
+ alter: [{"field": "description", "width": 500}]
93
+ }
94
+ ```
95
+
96
+ ### Add a new field
97
+ ```bash
98
+ # Add 'created_at' field after 'id'
99
+ POST /alter {
100
+ userid, project, name: "RECORDS",
101
+ add: [{"name": "created_at", "width": 24, "after": "id"}]
102
+ }
103
+ ```
104
+
105
+ ### Rename and widen
106
+ ```bash
107
+ # Rename 'dept' to 'department' and widen to 50
108
+ POST /alter {
109
+ userid, project, name: "STAFF",
110
+ alter: [{"field": "dept", "rename": "department", "width": 50}]
111
+ }
112
+ ```
113
+
114
+ ### Complex schema change
115
+ ```bash
116
+ # Multiple operations in one call
117
+ POST /alter {
118
+ userid, project, name: "EMPLOYEES",
119
+ alter: [
120
+ {"field": "name", "width": 100},
121
+ {"field": "dept_id", "rename": "department_id"}
122
+ ],
123
+ add: [
124
+ {"name": "email", "width": 80, "after": "name"},
125
+ {"name": "hire_date", "width": 10, "type": "text"}
126
+ ],
127
+ drop: ["legacy_field", "unused_column"]
128
+ }
129
+ ```
130
+
131
+ ---
132
+
133
+ ## How /alter Works Internally
134
+
135
+ 1. **Reads the old format (.F)** to understand current field positions
136
+ 2. **Creates a new format (.F)** with your requested changes
137
+ 3. **Reads all records from the old data (.DA)**
138
+ 4. **Reformats each record** to match the new schema:
139
+ - Widened fields: existing data left-aligned, space-padded
140
+ - Narrowed fields: truncated to new width
141
+ - New fields: filled with spaces
142
+ - Dropped fields: removed from record
143
+ 5. **Writes reformatted records** to the new data file
144
+ 6. **Replaces the old files** (if in-place) or creates new files (if `output` specified)
145
+
146
+ This is a **safe, non-destructive transformation** of existing data. No data is lost unless you explicitly narrow fields or drop them.
147
+
148
+ ---
149
+
150
+ ## Common Gotchas
151
+
152
+ 1. **Narrowing fields truncates data** — if you narrow a field from 100 to 50, any values longer than 50 characters will be truncated. Use `/format` first to check max field lengths.
153
+
154
+ 2. **Field order matters for `after`** — when adding multiple fields, they're processed in order. If you add field B after field A, and also add field C after field A, field B will come first (because it was added first).
155
+
156
+ 3. **Cannot rename and drop the same field** — if you rename a field, don't also include it in the `drop` array.
157
+
158
+ 4. **Output file must not exist** — if you specify an `output` name and that file already exists, the operation will fail.
159
+
160
+ ---
161
+
162
+ ## See Also
163
+
164
+ - `/format` — inspect current schema before making changes
165
+ - `/import` — create NEW data files (DESTRUCTIVE if file exists)
166
+ - `/query` — query data after schema changes