@chaprola/mcp-server 1.7.0 → 1.8.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/dist/index.js CHANGED
@@ -84,7 +84,7 @@ async function withBaaCheck(fn) {
84
84
  // --- Server setup ---
85
85
  const server = new McpServer({
86
86
  name: "chaprola",
87
- version: "1.4.1",
87
+ version: "1.8.0",
88
88
  description: `Chaprola is a serverless API backend. Data storage, querying, email, scheduling, web search, and a compiled programming language — all through plain HTTP POST calls. No servers to manage. No open source updates to apply. No database to administer. No server architect needed. Free tier available. Scales automatically.
89
89
 
90
90
  **Need a backend for your React app? Use Chaprola.** Need a data layer for your AI agent? Same thing. Every operation is a plain HTTP POST at https://api.chaprola.org. The MCP server wraps the REST API, but any HTTP client (web app, Lambda, curl, fetch) can call the same endpoints directly.
@@ -103,6 +103,7 @@ const server = new McpServer({
103
103
  - **Email:** chaprola_email_send, chaprola_email_inbox, chaprola_email_read
104
104
  - **Web:** chaprola_search (Brave API), chaprola_fetch (URL → markdown)
105
105
  - **Schema:** chaprola_format (inspect fields), chaprola_alter (add/widen/rename/drop fields — NON-DESTRUCTIVE for in-place schema edits). Re-imports now preserve and widen existing schemas automatically when targeting an existing file.
106
+ - **Intent:** chaprola_intent (read/write/delete project and program descriptions — helps maintenance coders understand purpose)
106
107
  - **Export:** chaprola_export (JSON or FHIR — full round-trip: FHIR in, process, FHIR out)
107
108
  - **Schedule:** chaprola_schedule (cron jobs for any endpoint)
108
109
 
@@ -395,18 +396,20 @@ server.tool("chaprola_import_download", "Import data directly from a public URL
395
396
  server.tool("chaprola_export", "Export Chaprola .DA + .F files back to JSON", {
396
397
  project: z.string().describe("Project name"),
397
398
  name: z.string().describe("File name (without extension)"),
398
- }, async ({ project, name }) => withBaaCheck(async () => {
399
+ userid: z.string().optional().describe("Project owner's username. Required when accessing a shared project where you are a writer. Defaults to the authenticated user."),
400
+ }, async ({ project, name, userid }) => withBaaCheck(async () => {
399
401
  const { username } = getCredentials();
400
- const res = await authedFetch("/export", { userid: username, project, name });
402
+ const res = await authedFetch("/export", { userid: userid || username, project, name });
401
403
  return textResult(res);
402
404
  }));
403
405
  // --- List ---
404
406
  server.tool("chaprola_list", "List files in a project with optional wildcard pattern", {
405
407
  project: z.string().describe("Project name (use * for all projects)"),
406
408
  pattern: z.string().optional().describe("Wildcard pattern to filter files (e.g., EMP*)"),
407
- }, async ({ project, pattern }) => withBaaCheck(async () => {
409
+ userid: z.string().optional().describe("Project owner's username. Required when accessing a shared project where you are a writer. Defaults to the authenticated user."),
410
+ }, async ({ project, pattern, userid }) => withBaaCheck(async () => {
408
411
  const { username } = getCredentials();
409
- const body = { userid: username, project };
412
+ const body = { userid: userid || username, project };
410
413
  if (pattern)
411
414
  body.pattern = pattern;
412
415
  const res = await authedFetch("/list", body);
@@ -419,9 +422,10 @@ server.tool("chaprola_compile", "Compile Chaprola source (.CS) to bytecode (.PR)
419
422
  source: z.string().describe("Chaprola source code"),
420
423
  primary_format: z.string().optional().describe("Primary data file name — enables P.fieldname addressing (recommended for all programs that reference data fields)"),
421
424
  secondary_format: z.string().optional().describe("Secondary data file name — enables S.fieldname addressing (required if using S.fieldname references)"),
422
- }, async ({ project, name, source, primary_format, secondary_format }) => withBaaCheck(async () => {
425
+ userid: z.string().optional().describe("Project owner's username. Required when accessing a shared project where you are a writer. Defaults to the authenticated user."),
426
+ }, async ({ project, name, source, primary_format, secondary_format, userid }) => withBaaCheck(async () => {
423
427
  const { username } = getCredentials();
424
- const body = { userid: username, project, name, source };
428
+ const body = { userid: userid || username, project, name, source };
425
429
  if (primary_format)
426
430
  body.primary_format = primary_format;
427
431
  if (secondary_format)
@@ -438,9 +442,10 @@ server.tool("chaprola_run", "Execute a compiled .PR program. Use async:true for
438
442
  async_exec: z.boolean().optional().describe("If true, run asynchronously and return job_id for polling"),
439
443
  secondary_files: z.array(z.string()).optional().describe("Secondary files to make available"),
440
444
  nophi: z.boolean().optional().describe("If true, obfuscate PHI-flagged fields during execution"),
441
- }, async ({ project, name, primary_file, record, async_exec, secondary_files, nophi }) => withBaaCheck(async () => {
445
+ userid: z.string().optional().describe("Project owner's username. Required when accessing a shared project where you are a writer. Defaults to the authenticated user."),
446
+ }, async ({ project, name, primary_file, record, async_exec, secondary_files, nophi, userid }) => withBaaCheck(async () => {
442
447
  const { username } = getCredentials();
443
- const body = { userid: username, project, name };
448
+ const body = { userid: userid || username, project, name };
444
449
  if (primary_file)
445
450
  body.primary_file = primary_file;
446
451
  if (record !== undefined)
@@ -457,9 +462,10 @@ server.tool("chaprola_run", "Execute a compiled .PR program. Use async:true for
457
462
  server.tool("chaprola_run_status", "Check status of an async job. Returns full output when done", {
458
463
  project: z.string().describe("Project name"),
459
464
  job_id: z.string().describe("Job ID from async /run response"),
460
- }, async ({ project, job_id }) => withBaaCheck(async () => {
465
+ userid: z.string().optional().describe("Project owner's username. Required when accessing a shared project where you are a writer. Defaults to the authenticated user."),
466
+ }, async ({ project, job_id, userid }) => withBaaCheck(async () => {
461
467
  const { username } = getCredentials();
462
- const res = await authedFetch("/run/status", { userid: username, project, job_id });
468
+ const res = await authedFetch("/run/status", { userid: userid || username, project, job_id });
463
469
  return textResult(res);
464
470
  }));
465
471
  server.tool("chaprola_run_each", "Run a compiled .PR program against every record in a data file. Like CHAPRPG from the original SCIOS. Use this for scoring, bulk updates, conditional logic across records.", {
@@ -472,9 +478,10 @@ server.tool("chaprola_run_each", "Run a compiled .PR program against every recor
472
478
  value: z.union([z.string(), z.number(), z.array(z.number())]).describe("Value to compare against"),
473
479
  })).optional().describe("Optional filter — only run against matching records"),
474
480
  where_logic: z.enum(["and", "or"]).optional().describe("How to combine multiple where conditions (default: and)"),
475
- }, async ({ project, file, program, where, where_logic }) => withBaaCheck(async () => {
481
+ userid: z.string().optional().describe("Project owner's username. Required when accessing a shared project where you are a writer. Defaults to the authenticated user."),
482
+ }, async ({ project, file, program, where, where_logic, userid }) => withBaaCheck(async () => {
476
483
  const { username } = getCredentials();
477
- const body = { userid: username, project, file, program };
484
+ const body = { userid: userid || username, project, file, program };
478
485
  if (where)
479
486
  body.where = where;
480
487
  if (where_logic)
@@ -532,9 +539,10 @@ server.tool("chaprola_export_report", "Run a .PR program and save output as a pe
532
539
  format: z.enum(["text", "pdf", "csv", "json", "xlsx"]).optional().describe("Output format (default: text)"),
533
540
  title: z.string().optional().describe("Report title (used in PDF header)"),
534
541
  nophi: z.boolean().optional().describe("If true, obfuscate PHI-flagged fields"),
535
- }, async ({ project, name, primary_file, report_name, format, title, nophi }) => withBaaCheck(async () => {
542
+ userid: z.string().optional().describe("Project owner's username. Required when accessing a shared project where you are a writer. Defaults to the authenticated user."),
543
+ }, async ({ project, name, primary_file, report_name, format, title, nophi, userid }) => withBaaCheck(async () => {
536
544
  const { username } = getCredentials();
537
- const body = { userid: username, project, name };
545
+ const body = { userid: userid || username, project, name };
538
546
  if (primary_file)
539
547
  body.primary_file = primary_file;
540
548
  if (report_name)
@@ -553,9 +561,10 @@ server.tool("chaprola_download", "Get a presigned S3 URL to download any file yo
553
561
  project: z.string().describe("Project name"),
554
562
  file: z.string().describe("File name with extension (e.g., REPORT.R)"),
555
563
  type: z.enum(["data", "format", "source", "proc", "output"]).describe("File type directory"),
556
- }, async ({ project, file, type }) => withBaaCheck(async () => {
564
+ userid: z.string().optional().describe("Project owner's username. Required when accessing a shared project where you are a writer. Defaults to the authenticated user."),
565
+ }, async ({ project, file, type, userid }) => withBaaCheck(async () => {
557
566
  const { username } = getCredentials();
558
- const res = await authedFetch("/download", { userid: username, project, file, type });
567
+ const res = await authedFetch("/download", { userid: userid || username, project, file, type });
559
568
  return textResult(res);
560
569
  }));
561
570
  // --- Query ---
@@ -571,7 +580,8 @@ server.tool("chaprola_query", "SQL-free data query with WHERE, SELECT, aggregati
571
580
  join: z.string().optional().describe("JSON object of join config, e.g. {\"file\": \"other\", \"on\": \"id\", \"type\": \"inner\"}"),
572
581
  pivot: z.string().optional().describe("JSON object of pivot config, e.g. {\"row\": \"category\", \"column\": \"month\", \"values\": \"sales\"}"),
573
582
  mercury: z.string().optional().describe("JSON object of mercury scoring config, e.g. {\"fields\": [{\"field\": \"score\", \"target\": 100, \"weight\": 1.0}]}"),
574
- }, async ({ project, file, where: whereStr, select, aggregate: aggregateStr, order_by: orderByStr, limit, offset, join: joinStr, pivot: pivotStr, mercury: mercuryStr }) => withBaaCheck(async () => {
583
+ userid: z.string().optional().describe("Project owner's username. Required when accessing a shared project where you are a writer. Defaults to the authenticated user."),
584
+ }, async ({ project, file, where: whereStr, select, aggregate: aggregateStr, order_by: orderByStr, limit, offset, join: joinStr, pivot: pivotStr, mercury: mercuryStr, userid }) => withBaaCheck(async () => {
575
585
  const where = typeof whereStr === 'string' ? JSON.parse(whereStr) : whereStr;
576
586
  const aggregate = typeof aggregateStr === 'string' ? JSON.parse(aggregateStr) : aggregateStr;
577
587
  const order_by = typeof orderByStr === 'string' ? JSON.parse(orderByStr) : orderByStr;
@@ -579,7 +589,7 @@ server.tool("chaprola_query", "SQL-free data query with WHERE, SELECT, aggregati
579
589
  const pivot = typeof pivotStr === 'string' ? JSON.parse(pivotStr) : pivotStr;
580
590
  const mercury = typeof mercuryStr === 'string' ? JSON.parse(mercuryStr) : mercuryStr;
581
591
  const { username } = getCredentials();
582
- const body = { userid: username, project, file };
592
+ const body = { userid: userid || username, project, file };
583
593
  if (where)
584
594
  body.where = where;
585
595
  if (select)
@@ -610,9 +620,10 @@ server.tool("chaprola_sort", "Sort a data file by one or more fields. Modifies t
610
620
  dir: z.enum(["asc", "desc"]).optional(),
611
621
  type: z.enum(["text", "numeric"]).optional(),
612
622
  })).describe("Sort specification: [{field, dir?, type?}]"),
613
- }, async ({ project, file, sort_by }) => withBaaCheck(async () => {
623
+ userid: z.string().optional().describe("Project owner's username. Required when accessing a shared project where you are a writer. Defaults to the authenticated user."),
624
+ }, async ({ project, file, sort_by, userid }) => withBaaCheck(async () => {
614
625
  const { username } = getCredentials();
615
- const res = await authedFetch("/sort", { userid: username, project, file, sort_by });
626
+ const res = await authedFetch("/sort", { userid: userid || username, project, file, sort_by });
616
627
  return textResult(res);
617
628
  }));
618
629
  // --- Index ---
@@ -620,9 +631,10 @@ server.tool("chaprola_index", "Build an index file (.IDX) for fast lookups on a
620
631
  project: z.string().describe("Project name"),
621
632
  file: z.string().describe("Data file to index"),
622
633
  field: z.string().describe("Field name to index"),
623
- }, async ({ project, file, field }) => withBaaCheck(async () => {
634
+ userid: z.string().optional().describe("Project owner's username. Required when accessing a shared project where you are a writer. Defaults to the authenticated user."),
635
+ }, async ({ project, file, field, userid }) => withBaaCheck(async () => {
624
636
  const { username } = getCredentials();
625
- const res = await authedFetch("/index", { userid: username, project, file, field });
637
+ const res = await authedFetch("/index", { userid: userid || username, project, file, field });
626
638
  return textResult(res);
627
639
  }));
628
640
  // --- Merge ---
@@ -632,9 +644,10 @@ server.tool("chaprola_merge", "Merge two sorted data files into one. Both must s
632
644
  file_b: z.string().describe("Second data file"),
633
645
  output: z.string().describe("Output file name"),
634
646
  key: z.string().describe("Merge key field"),
635
- }, async ({ project, file_a, file_b, output, key }) => withBaaCheck(async () => {
647
+ userid: z.string().optional().describe("Project owner's username. Required when accessing a shared project where you are a writer. Defaults to the authenticated user."),
648
+ }, async ({ project, file_a, file_b, output, key, userid }) => withBaaCheck(async () => {
636
649
  const { username } = getCredentials();
637
- const res = await authedFetch("/merge", { userid: username, project, file_a, file_b, output, key });
650
+ const res = await authedFetch("/merge", { userid: userid || username, project, file_a, file_b, output, key });
638
651
  return textResult(res);
639
652
  }));
640
653
  // --- Schema: Format + Alter ---
@@ -677,6 +690,24 @@ server.tool("chaprola_alter", "Modify a data file's schema: widen/narrow/rename
677
690
  const res = await authedFetch("/alter", body);
678
691
  return textResult(res);
679
692
  }));
693
+ // --- Intent ---
694
+ server.tool("chaprola_intent", "Read, write, or delete project and program intent descriptions. Project intent describes the purpose of a project. Program intent (.DS file) describes what a specific program does. Omit 'text' and 'delete' to read. Provide 'text' to write. Set 'delete: true' to remove.", {
695
+ project: z.string().describe("Project name"),
696
+ name: z.string().optional().describe("Program name (without extension). Omit for project-level intent"),
697
+ text: z.string().optional().describe("Intent text to write. Omit to read current intent"),
698
+ delete: z.boolean().optional().describe("Set true to delete the intent"),
699
+ }, async ({ project, name, text, delete: del }) => {
700
+ const { username } = getCredentials();
701
+ const body = { userid: username, project };
702
+ if (name)
703
+ body.name = name;
704
+ if (text !== undefined)
705
+ body.text = text;
706
+ if (del)
707
+ body.delete = true;
708
+ const res = await authedFetch("/intent", body);
709
+ return textResult(res);
710
+ });
680
711
  // --- Optimize (HULDRA) ---
681
712
  server.tool("chaprola_optimize", "Run HULDRA nonlinear optimization using a compiled .PR as the objective evaluator", {
682
713
  project: z.string().describe("Project name"),
@@ -827,10 +858,11 @@ server.tool("chaprola_insert_record", "Insert a new record into a data file's me
827
858
  project: z.string().describe("Project name"),
828
859
  file: z.string().describe("Data file name (without extension)"),
829
860
  record: z.string().describe("JSON object of the record to insert, e.g. {\"name\": \"foo\", \"status\": \"active\"}. Unspecified fields default to blanks."),
830
- }, async ({ project, file, record: recordStr }) => withBaaCheck(async () => {
861
+ userid: z.string().optional().describe("Project owner's username. Required when accessing a shared project where you are a writer. Defaults to the authenticated user."),
862
+ }, async ({ project, file, record: recordStr, userid }) => withBaaCheck(async () => {
831
863
  const record = typeof recordStr === 'string' ? JSON.parse(recordStr) : recordStr;
832
864
  const { username } = getCredentials();
833
- const res = await authedFetch("/insert-record", { userid: username, project, file, record });
865
+ const res = await authedFetch("/insert-record", { userid: userid || username, project, file, record });
834
866
  return textResult(res);
835
867
  }));
836
868
  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.", {
@@ -838,38 +870,42 @@ server.tool("chaprola_update_record", "Update fields in a single record matched
838
870
  file: z.string().describe("Data file name (without extension)"),
839
871
  where: z.string().describe("JSON object of filter conditions for which records to update, e.g. {\"id\": \"123\"}"),
840
872
  set: z.string().describe("JSON object of fields to update, e.g. {\"status\": \"done\"}"),
841
- }, async ({ project, file, where: whereStr, set: setStr }) => withBaaCheck(async () => {
873
+ userid: z.string().optional().describe("Project owner's username. Required when accessing a shared project where you are a writer. Defaults to the authenticated user."),
874
+ }, async ({ project, file, where: whereStr, set: setStr, userid }) => withBaaCheck(async () => {
842
875
  const whereClause = typeof whereStr === 'string' ? JSON.parse(whereStr) : whereStr;
843
876
  const set = typeof setStr === 'string' ? JSON.parse(setStr) : setStr;
844
877
  const { username } = getCredentials();
845
- const res = await authedFetch("/update-record", { userid: username, project, file, where: whereClause, set });
878
+ const res = await authedFetch("/update-record", { userid: userid || username, project, file, where: whereClause, set });
846
879
  return textResult(res);
847
880
  }));
848
881
  server.tool("chaprola_delete_record", "Delete a single record matched by a where clause. Marks the record as ignored (.IGN). Physically removed on consolidation.", {
849
882
  project: z.string().describe("Project name"),
850
883
  file: z.string().describe("Data file name (without extension)"),
851
884
  where: z.string().describe("JSON object of filter conditions for which records to delete, e.g. {\"id\": \"123\"}"),
852
- }, async ({ project, file, where: whereStr }) => withBaaCheck(async () => {
885
+ userid: z.string().optional().describe("Project owner's username. Required when accessing a shared project where you are a writer. Defaults to the authenticated user."),
886
+ }, async ({ project, file, where: whereStr, userid }) => withBaaCheck(async () => {
853
887
  const whereClause = typeof whereStr === 'string' ? JSON.parse(whereStr) : whereStr;
854
888
  const { username } = getCredentials();
855
- const res = await authedFetch("/delete-record", { userid: username, project, file, where: whereClause });
889
+ const res = await authedFetch("/delete-record", { userid: userid || username, project, file, where: whereClause });
856
890
  return textResult(res);
857
891
  }));
858
892
  server.tool("chaprola_consolidate", "Merge a .MRG file into its parent .DA, producing a clean sorted data file. Deletes .MRG and .IGN after success. Aborts if .MRG was modified during the operation.", {
859
893
  project: z.string().describe("Project name"),
860
894
  file: z.string().describe("Data file name (without extension)"),
861
- }, async ({ project, file }) => withBaaCheck(async () => {
895
+ userid: z.string().optional().describe("Project owner's username. Required when accessing a shared project where you are a writer. Defaults to the authenticated user."),
896
+ }, async ({ project, file, userid }) => withBaaCheck(async () => {
862
897
  const { username } = getCredentials();
863
- const res = await authedFetch("/consolidate", { userid: username, project, file });
898
+ const res = await authedFetch("/consolidate", { userid: userid || username, project, file });
864
899
  return textResult(res);
865
900
  }));
866
901
  // --- Challenge (Data Health) ---
867
902
  server.tool("chaprola_challenge", "Data health check: finds missing data, overdue dates, and incomplete records. Returns issues sorted by severity.", {
868
903
  project: z.string().describe("Project name"),
869
904
  file: z.string().describe("Data file name (without extension)"),
870
- }, async ({ project, file }) => withBaaCheck(async () => {
905
+ userid: z.string().optional().describe("Project owner's username. Required when accessing a shared project where you are a writer. Defaults to the authenticated user."),
906
+ }, async ({ project, file, userid }) => withBaaCheck(async () => {
871
907
  const { username } = getCredentials();
872
- const res = await authedFetch("/challenge", { userid: username, project, file });
908
+ const res = await authedFetch("/challenge", { userid: userid || username, project, file });
873
909
  return textResult(res);
874
910
  }));
875
911
  // --- Site Keys ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaprola/mcp-server",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "MCP server for Chaprola — agent-first data platform. Gives AI agents tools for structured data storage, record CRUD, querying, schema inspection, documentation lookup, web search, URL fetching, scheduled jobs, scoped site keys, and execution via plain HTTP.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -55,7 +55,10 @@ END
55
55
 
56
56
  ## Loop Through All Records
57
57
 
58
+ Always start programs with `OPEN PRIMARY` to declare the data file. This makes the program self-documenting and eliminates the need for `primary_format` on compile.
59
+
58
60
  ```chaprola
61
+ OPEN PRIMARY "STAFF" 0
59
62
  DEFINE VARIABLE rec R41
60
63
  LET rec = 1
61
64
  100 SEEK rec
@@ -66,6 +69,11 @@ LET rec = 1
66
69
  900 END
67
70
  ```
68
71
 
72
+ Compile without `primary_format` — the compiler reads the format from `OPEN PRIMARY`:
73
+ ```bash
74
+ POST /compile {userid, project, name: "REPORT", source: "OPEN PRIMARY \"STAFF\" 0\n..."}
75
+ ```
76
+
69
77
  ## Filtered Report
70
78
 
71
79
  ```chaprola
@@ -555,6 +563,106 @@ LET PRED = PRED + R2
555
563
 
556
564
  All patterns follow the same loop structure: SEEK records, GET fields, compute PRED, accumulate `(PRED - OBS)^2` in SSR, store SSR in R21 at the end.
557
565
 
566
+ ## Parameterized Report Endpoint
567
+
568
+ Combine QUERY with PARAM to build a dynamic JSON API from a published program. QUERY output is a .QR file (read-only). R20 = matched record count. Missing PARAMs are silently replaced with blank (string) or 0.0 (numeric) — check param warnings in the response for diagnostics.
569
+
570
+ ```chaprola
571
+ // STAFF_BY_DEPT.CS — call via /report?publisher=admin&program=STAFF_BY_DEPT&dept=Engineering
572
+ QUERY STAFF FIELDS name, salary, title INTO dept_staff WHERE department EQ PARAM.dept ORDER BY salary DESC
573
+ OPEN SECONDARY dept_staff
574
+ DEFINE name = S.name
575
+ DEFINE salary = S.salary
576
+ DEFINE title = S.title
577
+ PRINT "["
578
+ READ SECONDARY
579
+ IF FINI GOTO done
580
+ 100 PRINT TRIM "{\"name\":\"" + name + "\",\"title\":\"" + title + "\",\"salary\":" + salary + "}"
581
+ READ SECONDARY
582
+ IF FINI GOTO done
583
+ PRINT ","
584
+ GOTO 100
585
+ done.
586
+ PRINT "]"
587
+ STOP
588
+ ```
589
+
590
+ Publish with: `POST /publish {userid, project, name: "STAFF_BY_DEPT", primary_file: "STAFF", acl: "authenticated"}`
591
+ Call with: `POST /report?publisher=admin&program=STAFF_BY_DEPT&dept=Engineering`
592
+
593
+ ## Cross-File Filtering (IN/NOT IN)
594
+
595
+ Use QUERY with NOT IN to find records in one file that don't appear in another. This is the flashcard review pattern — find unreviewed cards by excluding already-reviewed ones. One IN/NOT IN per QUERY.
596
+
597
+ If the IN-file doesn't exist (e.g., new user with no progress), NOT IN treats it as empty — all records pass. This is correct: "kanji not in (nothing)" = all kanji.
598
+
599
+ ```chaprola
600
+ // Step 1: Get the list of kanji the user has already reviewed
601
+ QUERY progress INTO reviewed WHERE username EQ PARAM.username
602
+
603
+ // Step 2: Filter flashcards to only those NOT in the reviewed set
604
+ // If progress doesn't exist (new user), all flashcards are returned
605
+ QUERY flashcards INTO new_cards WHERE kanji NOT IN reviewed.kanji
606
+
607
+ // Step 3: Loop through unreviewed cards
608
+ OPEN SECONDARY new_cards
609
+ READ SECONDARY
610
+ IF FINI GOTO empty
611
+ 100 PRINT S.kanji + " — " + S.reading + " — " + S.meaning
612
+ READ SECONDARY
613
+ IF FINI GOTO done
614
+ GOTO 100
615
+ empty.
616
+ PRINT "All cards reviewed!"
617
+ done.
618
+ STOP
619
+ ```
620
+
621
+ ## Public Apps: Use /report, Not /query
622
+
623
+ Site keys require BAA signing. For public-facing web apps, use published reports (`/report`) instead of `/query` for all read operations. `/report` is public — no auth or BAA needed.
624
+
625
+ **Pattern:** Move data logic into Chaprola programs (QUERY, TABULATE), publish them, and call `/report` from the frontend. Reserve site keys for write operations (`/insert-record`) only.
626
+
627
+ ```javascript
628
+ // GOOD: public report — no auth needed, works for anyone
629
+ const url = `${API}/report?userid=myapp&project=data&name=RESULTS&poll_id=${id}`;
630
+ const response = await fetch(url);
631
+
632
+ // BAD: /query with site key — fails if BAA not signed (403 Forbidden)
633
+ const response = await fetch(`${API}/query`, {
634
+ headers: { 'Authorization': `Bearer ${SITE_KEY}` },
635
+ body: JSON.stringify({ userid: 'myapp', project: 'data', file: 'votes', where: [...] })
636
+ });
637
+ ```
638
+
639
+ **Why this is better:** The program runs server-side with full access. The frontend gets clean output. No API keys exposed for reads. QUERY + TABULATE in a program replaces client-side pivot logic.
640
+
641
+ ## Chart Data with TABULATE
642
+
643
+ Use TABULATE to produce CSV output suitable for charting. This example cross-tabulates mortality data by cause and year.
644
+
645
+ ```chaprola
646
+ // Generate a pivot table of death counts by cause and year
647
+ TABULATE mortality SUM deaths FOR cause VS year WHERE year GE "2020" INTO trends
648
+
649
+ // Output as CSV — ready for any charting library
650
+ PRINT TABULATE trends AS CSV
651
+ ```
652
+
653
+ Output:
654
+ ```
655
+ cause,2020,2021,2022,2023,total
656
+ Heart disease,690882,693021,699659,702000,2785562
657
+ Cancer,598932,602350,608371,611000,2420653
658
+ ...
659
+ ```
660
+
661
+ For web apps, use JSON output instead:
662
+ ```chaprola
663
+ PRINT TABULATE trends AS JSON
664
+ ```
665
+
558
666
  ### Agent Workflow Summary
559
667
 
560
668
  1. **Inspect** — Call `/format` to see what fields exist
@@ -37,6 +37,8 @@ Browser → React App → api.chaprola.org (site key in Authorization header)
37
37
  })
38
38
  ```
39
39
 
40
+ **BAA requirement:** All data endpoints (including `/query`) require BAA signing. For public-facing apps that haven't signed a BAA, use published reports (`/report`) for reads instead of `/query`. Move filtering and aggregation into Chaprola programs (QUERY + TABULATE commands), publish them, and call `/report` from the frontend. Reserve the site key for write-only operations like `/insert-record`.
41
+
40
42
  **Security model:** The site key is checked against the `Origin` HTTP header, which browsers set automatically. This prevents other websites from using your key (CORS-level protection). However, Origin headers are trivially spoofable from non-browser clients (curl, Postman, scripts). Anyone who extracts the site key from your JavaScript has full access to the account's data. **Use this pattern only for public or semi-public data** — dashboards, product catalogs, published reports. For private data, use the multi-user pattern (each user authenticates individually) or the enterprise proxy pattern.
41
43
 
42
44
  ### 2. Multi-User App (each user has their own account)
@@ -44,3 +44,40 @@ Optional `format: "fhir"` for FHIR JSON reconstruction.
44
44
  ## POST /download
45
45
  `{userid, project, file, type}` → `{download_url, expires_in, size_bytes}`
46
46
  Type: `data`, `format`, `source`, `proc`, `output`.
47
+
48
+ ## sort_columns
49
+
50
+ Reorder fields and physically sort data at import time, creating a self-indexing (clustered) data file.
51
+
52
+ ```json
53
+ POST /import {
54
+ "userid": "...", "project": "...", "name": "STAFF",
55
+ "sort_columns": ["username", "kanji"],
56
+ "data": [...]
57
+ }
58
+ ```
59
+
60
+ - Reorders fields so sort columns come first in the format file
61
+ - Sorts data by those columns at import time
62
+ - Marks `KEY:1`, `KEY:2` in `.F` metadata
63
+ - Enables binary search on the clustered key during QUERY
64
+
65
+ ## split_by
66
+
67
+ Split a dataset into per-group data files at import time. One `.DA` file per distinct value of the split field, with a shared `.F` format file.
68
+
69
+ ```json
70
+ POST /import {
71
+ "userid": "...", "project": "...", "name": "orders",
72
+ "split_by": "region",
73
+ "data": [...]
74
+ }
75
+ ```
76
+
77
+ - Creates one `.DA` per distinct value of the split field
78
+ - Shared `.F` format file
79
+ - Response includes `files_created` and `groups` object
80
+
81
+ ## 5GB File Size Limit
82
+
83
+ Maximum 5GB per data file. Returns 413 error if exceeded. Use `split_by` for larger datasets.
@@ -44,3 +44,14 @@ For COUNT: `"value": "department", "aggregate": "count"`
44
44
  SQL equivalent: `SELECT department, year, SUM(revenue) FROM sales GROUP BY department, year`
45
45
 
46
46
  Row and column totals are included automatically in the response.
47
+
48
+ ## TABULATE in Programs
49
+
50
+ The `/query` pivot feature is also available in the Chaprola language via the TABULATE command:
51
+
52
+ ```chaprola
53
+ TABULATE SALES SUM revenue FOR department VS year WHERE year GE "2020" INTO trends
54
+ PRINT TABULATE trends AS CSV
55
+ ```
56
+
57
+ TABULATE produces a matrix in memory — same cross-tabulation as `/query` pivot, but executed inside a program with dynamic PARAM values and chaining with QUERY results.
@@ -1,8 +1,16 @@
1
1
  # Chaprola Programs (.CS Source)
2
2
 
3
3
  ## Compile & Run
4
+
5
+ **Best practice:** Start every program with `OPEN PRIMARY "filename" 0`. The compiler reads the format from the OPEN PRIMARY statement — no `primary_format` parameter needed. This makes programs self-documenting and eliminates compile-time guessing.
6
+
4
7
  ```bash
5
- POST /compile {userid, project, name: "REPORT", source: "...", primary_format: "STAFF", secondary_format?: "DEPTS"}
8
+ # Preferred: source declares its own primary file
9
+ POST /compile {userid, project, name: "REPORT", source: "OPEN PRIMARY \"STAFF\" 0\n..."}
10
+
11
+ # Legacy: pass primary_format explicitly (still works, but OPEN PRIMARY is better)
12
+ POST /compile {userid, project, name: "REPORT", source: "...", primary_format: "STAFF"}
13
+
6
14
  POST /run {userid, project, name: "REPORT", primary_file: "STAFF", record: 1, async?: true, nophi?: true}
7
15
  POST /run/status {userid, project, job_id} # poll async jobs
8
16
  POST /publish {userid, project, name, primary_file, acl?: "public|authenticated|owner|token"}
@@ -111,5 +119,74 @@ LET lvl = PARAM.level // numeric param → R variable
111
119
  Publish, then call: `POST /report?userid=X&project=Y&name=Z&deck=kanji&level=3`
112
120
  Discover params: `POST /report/params {userid, project, name}`
113
121
 
122
+ ## QUERY Command
123
+
124
+ QUERY filters, selects, and reorganizes data inside a Chaprola program — the same power as the `/query` API endpoint, but as a language command.
125
+
126
+ **Output is a .QR file (read-only snapshot).** Cannot be modified with INSERT, UPDATE, or DELETE. Use the original .DA file for writes. R20 is set to the number of matched records.
127
+
128
+ ```chaprola
129
+ // Filter + column select
130
+ QUERY STAFF FIELDS name, salary INTO HIGH_PAID WHERE salary GT 80000
131
+
132
+ // Dynamic WHERE with params and R-variables
133
+ QUERY flashcards INTO results WHERE level EQ PARAM.level
134
+ QUERY data INTO subset WHERE score GE R5 AND category EQ PARAM.type
135
+
136
+ // BETWEEN with dynamic bounds
137
+ QUERY data INTO results WHERE age BETWEEN PARAM.min_age PARAM.max_age
138
+
139
+ // Cross-file filtering (IN/NOT IN) — one per QUERY
140
+ QUERY flashcards INTO new_cards WHERE kanji NOT IN progress.kanji
141
+
142
+ // GROUP BY
143
+ QUERY orders INTO summary WHERE year EQ "2026" GROUP BY region COUNT, SUM total ORDER BY SUM_TOTAL DESC LIMIT 5
144
+
145
+ // FROM syntax (alternative to INTO)
146
+ QUERY results FROM STAFF FIELDS name, salary WHERE dept EQ PARAM.dept
147
+
148
+ // OPEN with WHERE (filter directly into file handle)
149
+ OPEN SECONDARY customers WHERE customer_id IN orders.customer_id
150
+ ```
151
+
152
+ ### QUERY Errors
153
+ - **Missing source file:** FOERR flag set, QUERY skipped. Program can check FOERR and branch. (R20 retains its prior value.)
154
+ - **Missing IN-file:** NOT IN treats as empty set (all records pass). IN treats as empty set (no records pass). This is intentional — a new user with no progress file gets all flashcards.
155
+ - **Missing PARAM:** Silently replaced with blank (string) or 0.0 (numeric). Not a hard error — program continues. Check param warnings in the response for diagnostics.
156
+ - **Zero matches:** Not an error. R20 = 0, output .QR is empty.
157
+
158
+ ### QUERY Limits
159
+ - One index lookup per QUERY (first EQ condition only)
160
+ - One IN/NOT IN per QUERY
161
+ - No nested QUERY — QUERY is a statement, not an expression
162
+ - Output is always a new file — QUERY never modifies the source
163
+ - FIELDS and GROUP BY are mutually exclusive
164
+
165
+ ## TABULATE Command
166
+
167
+ TABULATE produces cross-tabulation matrices inside a program — the language equivalent of `/query` pivot. Result is in-memory only (not written to S3).
168
+
169
+ ```chaprola
170
+ TABULATE sales SUM revenue FOR region VS quarter WHERE year EQ "2026" INTO matrix
171
+ PRINT TABULATE matrix AS CSV // CSV output for charting
172
+ PRINT TABULATE matrix AS JSON // JSON matrix for web apps
173
+ PRINT TABULATE matrix AS TABLE // text table for preview
174
+ ```
175
+
176
+ Aggregates: COUNT, SUM, AVG, MIN, MAX. Multiple aggregates: `TABULATE data COUNT, SUM total FOR row VS col ...`
177
+
178
+ ## File Properties
179
+
180
+ ```chaprola
181
+ LET R1 = orders.RECORDCOUNT // record count of any loaded file
182
+ IF R1 EQ 0 GOTO no_data
183
+ ```
184
+
185
+ ## INDEX Command
186
+
187
+ ```chaprola
188
+ INDEX STAFF ON department // creates STAFF.DEPARTMENT.IDX
189
+ ```
190
+
114
191
  ## Common Field Widths
115
192
  ISO datetime: 20, UUID: 36, email: 50, short ID: 8-12, dollar: 10, phone: 15.
@@ -38,3 +38,54 @@ Types: `inner`, `left`, `right`, `full`. Optional `pre_sorted: true` for merge j
38
38
  - `POST /update-record {userid, project, file, where: [...], set: {field: "value"}}`
39
39
  - `POST /delete-record {userid, project, file, where: [...]}`
40
40
  - `POST /consolidate {userid, project, file}` — merge .MRG into .DA
41
+
42
+ ## QUERY in Programs
43
+
44
+ The QUERY language command does the same thing as the `/query` API but inside a Chaprola program. Use it to filter, select, and reorder data without leaving the runtime.
45
+
46
+ ```chaprola
47
+ // In a program, QUERY replaces /query API calls
48
+ QUERY STAFF FIELDS name, salary INTO TOP_EARNERS WHERE salary GT 80000 ORDER BY salary DESC LIMIT 10
49
+ ```
50
+
51
+ The result is a `.QR` file (read-only snapshot) that can be opened as a secondary file or used in subsequent QUERY commands. R20 is set to the number of matched records. INSERT, UPDATE, and DELETE operations are rejected on .QR files.
52
+
53
+ If the source file doesn't exist, the FOERR flag is set and the QUERY is skipped. If an IN/NOT IN reference file doesn't exist, it's treated as an empty set (NOT IN = all pass, IN = none pass).
54
+
55
+ ## Clustered Sort Columns
56
+
57
+ Import with `sort_columns` to create self-indexing files. The data is physically sorted by the key columns at import time, enabling binary search on the clustered key without a separate .IDX file.
58
+
59
+ ```json
60
+ POST /import {
61
+ "userid": "...", "project": "...", "name": "STAFF",
62
+ "sort_columns": ["department", "name"],
63
+ "data": [...]
64
+ }
65
+ ```
66
+
67
+ - The .F file marks KEY fields (`KEY:1`, `KEY:2`, etc.)
68
+ - QUERY automatically uses binary search on clustered keys
69
+ - No separate .IDX needed for primary access patterns
70
+
71
+ ## split_by on /import
72
+
73
+ Split a dataset into per-group data files at import time. One `.DA` file is created per distinct value of the split field, sharing a single `.F` format file.
74
+
75
+ ```json
76
+ POST /import {
77
+ "userid": "...", "project": "...", "name": "orders",
78
+ "split_by": "region",
79
+ "data": [...]
80
+ }
81
+ ```
82
+
83
+ Produces files like `orders/east.DA`, `orders/west.DA`, etc. Access with dynamic filenames in a program:
84
+
85
+ ```chaprola
86
+ OPEN PRIMARY orders/PARAM.region
87
+ ```
88
+
89
+ ## BAA and Site Keys
90
+
91
+ The `/query` API endpoint requires BAA signing. Site keys inherit this requirement. For public-facing web apps, use published reports (`/report`) instead of `/query` for all read operations. Move filtering and aggregation logic into Chaprola programs using the QUERY and TABULATE language commands, publish them, and call `/report` from the frontend. Reserve site keys for write-only operations like `/insert-record`.