@the-cascade-protocol/cli 0.2.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.
Files changed (165) hide show
  1. package/.dockerignore +7 -0
  2. package/.eslintrc.json +23 -0
  3. package/.prettierrc +7 -0
  4. package/DOCKER.md +36 -0
  5. package/Dockerfile +18 -0
  6. package/README.md +69 -0
  7. package/dist/commands/capabilities.d.ts +9 -0
  8. package/dist/commands/capabilities.d.ts.map +1 -0
  9. package/dist/commands/capabilities.js +194 -0
  10. package/dist/commands/capabilities.js.map +1 -0
  11. package/dist/commands/conformance.d.ts +15 -0
  12. package/dist/commands/conformance.d.ts.map +1 -0
  13. package/dist/commands/conformance.js +348 -0
  14. package/dist/commands/conformance.js.map +1 -0
  15. package/dist/commands/convert.d.ts +21 -0
  16. package/dist/commands/convert.d.ts.map +1 -0
  17. package/dist/commands/convert.js +134 -0
  18. package/dist/commands/convert.js.map +1 -0
  19. package/dist/commands/pod/export.d.ts +8 -0
  20. package/dist/commands/pod/export.d.ts.map +1 -0
  21. package/dist/commands/pod/export.js +72 -0
  22. package/dist/commands/pod/export.js.map +1 -0
  23. package/dist/commands/pod/helpers.d.ts +79 -0
  24. package/dist/commands/pod/helpers.d.ts.map +1 -0
  25. package/dist/commands/pod/helpers.js +369 -0
  26. package/dist/commands/pod/helpers.js.map +1 -0
  27. package/dist/commands/pod/index.d.ts +20 -0
  28. package/dist/commands/pod/index.d.ts.map +1 -0
  29. package/dist/commands/pod/index.js +29 -0
  30. package/dist/commands/pod/index.js.map +1 -0
  31. package/dist/commands/pod/info.d.ts +9 -0
  32. package/dist/commands/pod/info.d.ts.map +1 -0
  33. package/dist/commands/pod/info.js +196 -0
  34. package/dist/commands/pod/info.js.map +1 -0
  35. package/dist/commands/pod/init.d.ts +9 -0
  36. package/dist/commands/pod/init.d.ts.map +1 -0
  37. package/dist/commands/pod/init.js +251 -0
  38. package/dist/commands/pod/init.js.map +1 -0
  39. package/dist/commands/pod/query.d.ts +9 -0
  40. package/dist/commands/pod/query.d.ts.map +1 -0
  41. package/dist/commands/pod/query.js +169 -0
  42. package/dist/commands/pod/query.js.map +1 -0
  43. package/dist/commands/pod 2.js +1017 -0
  44. package/dist/commands/pod.d.ts +28 -0
  45. package/dist/commands/pod.d.ts 2.map +1 -0
  46. package/dist/commands/pod.d.ts.map +1 -0
  47. package/dist/commands/pod.js +1031 -0
  48. package/dist/commands/pod.js 2.map +1 -0
  49. package/dist/commands/pod.js.map +1 -0
  50. package/dist/commands/serve.d.ts +33 -0
  51. package/dist/commands/serve.d.ts.map +1 -0
  52. package/dist/commands/serve.js +74 -0
  53. package/dist/commands/serve.js.map +1 -0
  54. package/dist/commands/validate.d.ts +18 -0
  55. package/dist/commands/validate.d.ts.map +1 -0
  56. package/dist/commands/validate.js +275 -0
  57. package/dist/commands/validate.js.map +1 -0
  58. package/dist/index.d.ts +19 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +49 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/lib/fhir-converter/cascade-to-fhir.d.ts +17 -0
  63. package/dist/lib/fhir-converter/cascade-to-fhir.d.ts.map +1 -0
  64. package/dist/lib/fhir-converter/cascade-to-fhir.js +358 -0
  65. package/dist/lib/fhir-converter/cascade-to-fhir.js.map +1 -0
  66. package/dist/lib/fhir-converter/converters-clinical.d.ts +29 -0
  67. package/dist/lib/fhir-converter/converters-clinical.d.ts.map +1 -0
  68. package/dist/lib/fhir-converter/converters-clinical.js +391 -0
  69. package/dist/lib/fhir-converter/converters-clinical.js.map +1 -0
  70. package/dist/lib/fhir-converter/converters-demographics.d.ts +20 -0
  71. package/dist/lib/fhir-converter/converters-demographics.d.ts.map +1 -0
  72. package/dist/lib/fhir-converter/converters-demographics.js +242 -0
  73. package/dist/lib/fhir-converter/converters-demographics.js.map +1 -0
  74. package/dist/lib/fhir-converter/fhir-to-cascade.d.ts +17 -0
  75. package/dist/lib/fhir-converter/fhir-to-cascade.d.ts.map +1 -0
  76. package/dist/lib/fhir-converter/fhir-to-cascade.js +63 -0
  77. package/dist/lib/fhir-converter/fhir-to-cascade.js.map +1 -0
  78. package/dist/lib/fhir-converter/index.d.ts +36 -0
  79. package/dist/lib/fhir-converter/index.d.ts.map +1 -0
  80. package/dist/lib/fhir-converter/index.js +187 -0
  81. package/dist/lib/fhir-converter/index.js.map +1 -0
  82. package/dist/lib/fhir-converter/types.d.ts +77 -0
  83. package/dist/lib/fhir-converter/types.d.ts.map +1 -0
  84. package/dist/lib/fhir-converter/types.js +236 -0
  85. package/dist/lib/fhir-converter/types.js.map +1 -0
  86. package/dist/lib/fhir-converter.d.ts +62 -0
  87. package/dist/lib/fhir-converter.d.ts.map +1 -0
  88. package/dist/lib/fhir-converter.js +1474 -0
  89. package/dist/lib/fhir-converter.js.map +1 -0
  90. package/dist/lib/mcp/audit.d.ts +24 -0
  91. package/dist/lib/mcp/audit.d.ts.map +1 -0
  92. package/dist/lib/mcp/audit.js +85 -0
  93. package/dist/lib/mcp/audit.js.map +1 -0
  94. package/dist/lib/mcp/server.d.ts +38 -0
  95. package/dist/lib/mcp/server.d.ts.map +1 -0
  96. package/dist/lib/mcp/server.js +172 -0
  97. package/dist/lib/mcp/server.js.map +1 -0
  98. package/dist/lib/mcp/tools.d.ts +47 -0
  99. package/dist/lib/mcp/tools.d.ts.map +1 -0
  100. package/dist/lib/mcp/tools.js +547 -0
  101. package/dist/lib/mcp/tools.js.map +1 -0
  102. package/dist/lib/output.d.ts +26 -0
  103. package/dist/lib/output.d.ts.map +1 -0
  104. package/dist/lib/output.js +64 -0
  105. package/dist/lib/output.js.map +1 -0
  106. package/dist/lib/shacl-validator.d.ts +53 -0
  107. package/dist/lib/shacl-validator.d.ts.map +1 -0
  108. package/dist/lib/shacl-validator.js +245 -0
  109. package/dist/lib/shacl-validator.js.map +1 -0
  110. package/dist/lib/turtle-parser.d.ts +64 -0
  111. package/dist/lib/turtle-parser.d.ts.map +1 -0
  112. package/dist/lib/turtle-parser.js +236 -0
  113. package/dist/lib/turtle-parser.js.map +1 -0
  114. package/dist/shapes/checkup.shapes.ttl +1459 -0
  115. package/dist/shapes/clinical.shapes.ttl +1350 -0
  116. package/dist/shapes/clinical.ttl +1369 -0
  117. package/dist/shapes/core.shapes.ttl +450 -0
  118. package/dist/shapes/core.ttl +603 -0
  119. package/dist/shapes/coverage.shapes.ttl +214 -0
  120. package/dist/shapes/coverage.ttl +182 -0
  121. package/dist/shapes/health.shapes.ttl +697 -0
  122. package/dist/shapes/health.ttl +859 -0
  123. package/dist/shapes/pots.shapes.ttl +481 -0
  124. package/package.json +54 -0
  125. package/src/commands/capabilities.ts +235 -0
  126. package/src/commands/conformance.ts +447 -0
  127. package/src/commands/convert.ts +164 -0
  128. package/src/commands/pod/export.ts +85 -0
  129. package/src/commands/pod/helpers.ts +449 -0
  130. package/src/commands/pod/index.ts +32 -0
  131. package/src/commands/pod/info.ts +239 -0
  132. package/src/commands/pod/init.ts +273 -0
  133. package/src/commands/pod/query.ts +224 -0
  134. package/src/commands/serve.ts +92 -0
  135. package/src/commands/validate.ts +303 -0
  136. package/src/index.ts +58 -0
  137. package/src/lib/fhir-converter/cascade-to-fhir.ts +369 -0
  138. package/src/lib/fhir-converter/converters-clinical.ts +446 -0
  139. package/src/lib/fhir-converter/converters-demographics.ts +270 -0
  140. package/src/lib/fhir-converter/fhir-to-cascade.ts +82 -0
  141. package/src/lib/fhir-converter/index.ts +215 -0
  142. package/src/lib/fhir-converter/types.ts +318 -0
  143. package/src/lib/mcp/audit.ts +107 -0
  144. package/src/lib/mcp/server.ts +192 -0
  145. package/src/lib/mcp/tools.ts +668 -0
  146. package/src/lib/output.ts +76 -0
  147. package/src/lib/shacl-validator.ts +314 -0
  148. package/src/lib/turtle-parser.ts +277 -0
  149. package/src/shapes/checkup.shapes.ttl +1459 -0
  150. package/src/shapes/clinical.shapes.ttl +1350 -0
  151. package/src/shapes/clinical.ttl +1369 -0
  152. package/src/shapes/core.shapes.ttl +450 -0
  153. package/src/shapes/core.ttl +603 -0
  154. package/src/shapes/coverage.shapes.ttl +214 -0
  155. package/src/shapes/coverage.ttl +182 -0
  156. package/src/shapes/health.shapes.ttl +697 -0
  157. package/src/shapes/health.ttl +859 -0
  158. package/src/shapes/pots.shapes.ttl +481 -0
  159. package/test-fixtures/fhir-bundle-example.json +216 -0
  160. package/test-fixtures/fhir-medication-example.json +18 -0
  161. package/tests/cli.test.ts +126 -0
  162. package/tests/fhir-converter.test.ts +874 -0
  163. package/tests/mcp-server.test.ts +396 -0
  164. package/tests/pod.test.ts +400 -0
  165. package/tsconfig.json +24 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * cascade convert --from <format> --to <format> [file]
3
+ *
4
+ * Convert between health data formats.
5
+ * Supports FHIR R4, Cascade Protocol Turtle, and JSON-LD.
6
+ *
7
+ * Options:
8
+ * --from <format> Source format (fhir|cascade|c-cda)
9
+ * --to <format> Target format (turtle|jsonld|fhir|cascade)
10
+ * --format <output> Output serialization format (turtle|jsonld) [default: turtle]
11
+ * --json Output results as JSON envelope (machine-readable)
12
+ * --verbose Show detailed conversion information
13
+ *
14
+ * Supports stdin piping:
15
+ * cat patient.json | cascade convert --from fhir --to cascade
16
+ *
17
+ * Zero network calls. All conversion is local.
18
+ */
19
+
20
+ import { Command } from 'commander';
21
+ import { readFileSync } from 'node:fs';
22
+ import { printResult, printError, printVerbose, type OutputOptions } from '../lib/output.js';
23
+ import { convert, detectFormat, type InputFormat, type OutputFormat } from '../lib/fhir-converter/index.js';
24
+
25
+ /**
26
+ * Read input from file or stdin.
27
+ * If file is provided, reads from disk.
28
+ * Otherwise reads all of stdin synchronously.
29
+ */
30
+ function readInput(file: string | undefined): string {
31
+ if (file) {
32
+ return readFileSync(file, 'utf-8');
33
+ }
34
+ // Read from stdin
35
+ return readFileSync(0, 'utf-8');
36
+ }
37
+
38
+ export function registerConvertCommand(program: Command): void {
39
+ program
40
+ .command('convert')
41
+ .description('Convert between health data formats')
42
+ .argument('[file]', 'Input file (reads from stdin if omitted)')
43
+ .requiredOption('--from <format>', 'Source format (fhir|cascade|c-cda)')
44
+ .requiredOption('--to <format>', 'Target format (turtle|jsonld|fhir|cascade)')
45
+ .option('--format <output>', 'Output serialization format (turtle|jsonld)', 'turtle')
46
+ .action(
47
+ async (
48
+ file: string | undefined,
49
+ options: { from: string; to: string; format: string },
50
+ ) => {
51
+ const globalOpts = program.opts() as OutputOptions;
52
+
53
+ printVerbose(`Converting from ${options.from} to ${options.to}`, globalOpts);
54
+ if (file) {
55
+ printVerbose(`Input file: ${file}`, globalOpts);
56
+ } else {
57
+ printVerbose('Reading from stdin', globalOpts);
58
+ }
59
+ printVerbose(`Output format: ${options.format}`, globalOpts);
60
+
61
+ // 1. Read input
62
+ let input: string;
63
+ try {
64
+ input = readInput(file);
65
+ } catch (err: any) {
66
+ printError(`Failed to read input: ${err.message}`, globalOpts);
67
+ process.exitCode = 1;
68
+ return;
69
+ }
70
+
71
+ if (!input.trim()) {
72
+ printError('Empty input', globalOpts);
73
+ process.exitCode = 1;
74
+ return;
75
+ }
76
+
77
+ // 2. Validate source/target formats
78
+ const validInputFormats = ['fhir', 'cascade', 'c-cda'];
79
+ const validOutputFormats = ['turtle', 'jsonld', 'fhir', 'cascade'];
80
+
81
+ if (!validInputFormats.includes(options.from)) {
82
+ printError(`Invalid source format: ${options.from}. Valid: ${validInputFormats.join(', ')}`, globalOpts);
83
+ process.exitCode = 1;
84
+ return;
85
+ }
86
+
87
+ if (!validOutputFormats.includes(options.to)) {
88
+ printError(`Invalid target format: ${options.to}. Valid: ${validOutputFormats.join(', ')}`, globalOpts);
89
+ process.exitCode = 1;
90
+ return;
91
+ }
92
+
93
+ // 3. Auto-detect format if helpful (validate matches declared)
94
+ const detected = detectFormat(input);
95
+ if (detected && detected !== options.from) {
96
+ printVerbose(
97
+ `Note: Input appears to be ${detected} but --from says ${options.from}. Proceeding with declared format.`,
98
+ globalOpts,
99
+ );
100
+ }
101
+
102
+ // 4. Run conversion
103
+ const outputSerialization = (options.format === 'jsonld' ? 'jsonld' : 'turtle') as 'turtle' | 'jsonld';
104
+ const result = await convert(
105
+ input,
106
+ options.from as InputFormat,
107
+ options.to as OutputFormat,
108
+ outputSerialization,
109
+ );
110
+
111
+ // 5. Output
112
+ if (!result.success) {
113
+ for (const err of result.errors) {
114
+ printError(err, globalOpts);
115
+ }
116
+ for (const warn of result.warnings) {
117
+ printVerbose(`Warning: ${warn}`, globalOpts);
118
+ }
119
+ process.exitCode = 1;
120
+ return;
121
+ }
122
+
123
+ // Print warnings in verbose mode
124
+ for (const warn of result.warnings) {
125
+ printVerbose(`Warning: ${warn}`, globalOpts);
126
+ }
127
+
128
+ if (globalOpts.json) {
129
+ // JSON envelope for machine-readable output
130
+ printResult(
131
+ {
132
+ success: true,
133
+ from: options.from,
134
+ to: options.to,
135
+ format: result.format,
136
+ resourceCount: result.resourceCount,
137
+ warnings: result.warnings,
138
+ output: result.output,
139
+ resources: result.results.map(r => ({
140
+ resourceType: r.resourceType,
141
+ cascadeType: r.cascadeType,
142
+ warnings: r.warnings,
143
+ })),
144
+ },
145
+ globalOpts,
146
+ );
147
+ } else {
148
+ // Direct output (Turtle, JSON-LD, or FHIR JSON)
149
+ console.log(result.output);
150
+
151
+ // Print summary to stderr so it does not pollute piped output
152
+ if (result.resourceCount > 0) {
153
+ console.error(
154
+ `Converted ${result.resourceCount} resource${result.resourceCount > 1 ? 's' : ''} ` +
155
+ `(${options.from} -> ${result.format})`,
156
+ );
157
+ }
158
+ if (result.warnings.length > 0) {
159
+ console.error(`${result.warnings.length} warning${result.warnings.length > 1 ? 's' : ''}`);
160
+ }
161
+ }
162
+ },
163
+ );
164
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * cascade pod export <pod-dir>
3
+ *
4
+ * Export pod data as a ZIP archive or directory copy.
5
+ */
6
+
7
+ import type { Command } from 'commander';
8
+ import * as path from 'path';
9
+ import { printResult, printError, printVerbose, type OutputOptions } from '../../lib/output.js';
10
+ import { resolvePodDir, isDirectory, copyDirectory, createZipArchive } from './helpers.js';
11
+
12
+ export function registerExportSubcommand(pod: Command, program: Command): void {
13
+ pod
14
+ .command('export')
15
+ .description('Export pod data')
16
+ .argument('<pod-dir>', 'Path to the Cascade Pod')
17
+ .option('--format <fmt>', 'Export format (zip|directory)', 'zip')
18
+ .option('--output <path>', 'Output path for export')
19
+ .action(async (podDir: string, options: { format: string; output?: string }) => {
20
+ const globalOpts = program.opts() as OutputOptions;
21
+ const absDir = resolvePodDir(podDir);
22
+
23
+ printVerbose(`Exporting pod: ${absDir} as ${options.format}`, globalOpts);
24
+
25
+ // Validate pod exists
26
+ if (!(await isDirectory(absDir))) {
27
+ printError(`Pod directory not found: ${absDir}`, globalOpts);
28
+ process.exitCode = 1;
29
+ return;
30
+ }
31
+
32
+ try {
33
+ if (options.format === 'directory') {
34
+ // Copy to new directory
35
+ const outputDir = options.output ?? `${absDir}-export`;
36
+ await copyDirectory(absDir, outputDir);
37
+
38
+ if (globalOpts.json) {
39
+ printResult(
40
+ {
41
+ status: 'exported',
42
+ format: 'directory',
43
+ source: absDir,
44
+ output: outputDir,
45
+ },
46
+ globalOpts,
47
+ );
48
+ } else {
49
+ console.log(`Pod exported to directory: ${outputDir}`);
50
+ }
51
+ } else if (options.format === 'zip') {
52
+ // Create ZIP archive
53
+ const outputZip =
54
+ options.output ?? `${path.basename(absDir)}.zip`;
55
+ const absOutputZip = path.resolve(process.cwd(), outputZip);
56
+
57
+ await createZipArchive(absDir, absOutputZip);
58
+
59
+ if (globalOpts.json) {
60
+ printResult(
61
+ {
62
+ status: 'exported',
63
+ format: 'zip',
64
+ source: absDir,
65
+ output: absOutputZip,
66
+ },
67
+ globalOpts,
68
+ );
69
+ } else {
70
+ console.log(`Pod exported to ZIP: ${absOutputZip}`);
71
+ }
72
+ } else {
73
+ printError(
74
+ `Unknown export format: ${options.format}. Use 'zip' or 'directory'.`,
75
+ globalOpts,
76
+ );
77
+ process.exitCode = 1;
78
+ }
79
+ } catch (err: unknown) {
80
+ const message = err instanceof Error ? err.message : String(err);
81
+ printError(`Failed to export pod: ${message}`, globalOpts);
82
+ process.exitCode = 1;
83
+ }
84
+ });
85
+ }
@@ -0,0 +1,449 @@
1
+ /**
2
+ * Shared helpers for pod subcommands.
3
+ *
4
+ * Includes file-system utilities, the data type registry, parsing helpers,
5
+ * and display-formatting functions used by multiple pod subcommands.
6
+ */
7
+
8
+ import * as fs from 'fs/promises';
9
+ import * as path from 'path';
10
+ import {
11
+ parseTurtleFile,
12
+ getProperties,
13
+ shortenIRI,
14
+ extractLabel,
15
+ CASCADE_NAMESPACES,
16
+ } from '../../lib/turtle-parser.js';
17
+
18
+ // ─── Data Type Registry ──────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Known data file types and the rdf:type IRIs that identify records in them.
22
+ */
23
+ export interface DataTypeInfo {
24
+ label: string;
25
+ rdfTypes: string[];
26
+ directory: 'clinical' | 'wellness';
27
+ filename: string;
28
+ }
29
+
30
+ export const DATA_TYPES: Record<string, DataTypeInfo> = {
31
+ medications: {
32
+ label: 'Medications',
33
+ rdfTypes: [CASCADE_NAMESPACES.health + 'MedicationRecord'],
34
+ directory: 'clinical',
35
+ filename: 'medications.ttl',
36
+ },
37
+ conditions: {
38
+ label: 'Conditions',
39
+ rdfTypes: [CASCADE_NAMESPACES.health + 'ConditionRecord'],
40
+ directory: 'clinical',
41
+ filename: 'conditions.ttl',
42
+ },
43
+ allergies: {
44
+ label: 'Allergies',
45
+ rdfTypes: [CASCADE_NAMESPACES.health + 'AllergyRecord'],
46
+ directory: 'clinical',
47
+ filename: 'allergies.ttl',
48
+ },
49
+ 'lab-results': {
50
+ label: 'Lab Results',
51
+ rdfTypes: [CASCADE_NAMESPACES.health + 'LabResultRecord'],
52
+ directory: 'clinical',
53
+ filename: 'lab-results.ttl',
54
+ },
55
+ immunizations: {
56
+ label: 'Immunizations',
57
+ rdfTypes: [CASCADE_NAMESPACES.health + 'ImmunizationRecord'],
58
+ directory: 'clinical',
59
+ filename: 'immunizations.ttl',
60
+ },
61
+ 'vital-signs': {
62
+ label: 'Vital Signs',
63
+ rdfTypes: [CASCADE_NAMESPACES.clinical + 'VitalSign'],
64
+ directory: 'clinical',
65
+ filename: 'vital-signs.ttl',
66
+ },
67
+ insurance: {
68
+ label: 'Insurance',
69
+ rdfTypes: [CASCADE_NAMESPACES.clinical + 'CoverageRecord'],
70
+ directory: 'clinical',
71
+ filename: 'insurance.ttl',
72
+ },
73
+ 'patient-profile': {
74
+ label: 'Patient Profile',
75
+ rdfTypes: [CASCADE_NAMESPACES.cascade + 'PatientProfile'],
76
+ directory: 'clinical',
77
+ filename: 'patient-profile.ttl',
78
+ },
79
+ 'heart-rate': {
80
+ label: 'Heart Rate',
81
+ rdfTypes: [CASCADE_NAMESPACES.health + 'DailyVitalReading', CASCADE_NAMESPACES.health + 'HeartRateData'],
82
+ directory: 'wellness',
83
+ filename: 'heart-rate.ttl',
84
+ },
85
+ 'blood-pressure': {
86
+ label: 'Blood Pressure',
87
+ rdfTypes: [
88
+ 'http://hl7.org/fhir/Observation',
89
+ CASCADE_NAMESPACES.health + 'BloodPressureData',
90
+ ],
91
+ directory: 'wellness',
92
+ filename: 'blood-pressure.ttl',
93
+ },
94
+ activity: {
95
+ label: 'Activity',
96
+ rdfTypes: [CASCADE_NAMESPACES.health + 'DailyActivitySnapshot', CASCADE_NAMESPACES.health + 'ActivityData'],
97
+ directory: 'wellness',
98
+ filename: 'activity.ttl',
99
+ },
100
+ sleep: {
101
+ label: 'Sleep',
102
+ rdfTypes: [CASCADE_NAMESPACES.health + 'DailySleepSnapshot', CASCADE_NAMESPACES.health + 'SleepData'],
103
+ directory: 'wellness',
104
+ filename: 'sleep.ttl',
105
+ },
106
+ supplements: {
107
+ label: 'Supplements',
108
+ rdfTypes: [CASCADE_NAMESPACES.clinical + 'Supplement'],
109
+ directory: 'wellness',
110
+ filename: 'supplements.ttl',
111
+ },
112
+ };
113
+
114
+ // Re-export CASCADE_NAMESPACES for convenience
115
+ export { CASCADE_NAMESPACES };
116
+
117
+ // ─── File-System Helpers ─────────────────────────────────────────────────────
118
+
119
+ /**
120
+ * Resolve a pod directory path to an absolute path.
121
+ */
122
+ export function resolvePodDir(podDir: string): string {
123
+ return path.resolve(process.cwd(), podDir);
124
+ }
125
+
126
+ /**
127
+ * Check if a path exists and is a directory.
128
+ */
129
+ export async function isDirectory(dirPath: string): Promise<boolean> {
130
+ try {
131
+ const stat = await fs.stat(dirPath);
132
+ return stat.isDirectory();
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Check if a file exists.
140
+ */
141
+ export async function fileExists(filePath: string): Promise<boolean> {
142
+ try {
143
+ await fs.access(filePath);
144
+ return true;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Discover all TTL files in a pod directory recursively.
152
+ */
153
+ export async function discoverTtlFiles(podDir: string): Promise<string[]> {
154
+ const files: string[] = [];
155
+
156
+ async function walk(dir: string): Promise<void> {
157
+ const entries = await fs.readdir(dir, { withFileTypes: true });
158
+ for (const entry of entries) {
159
+ const fullPath = path.join(dir, entry.name);
160
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
161
+ await walk(fullPath);
162
+ } else if (entry.isFile() && entry.name.endsWith('.ttl')) {
163
+ files.push(fullPath);
164
+ }
165
+ }
166
+ }
167
+
168
+ await walk(podDir);
169
+ return files.sort();
170
+ }
171
+
172
+ // ─── Parsing Helpers ─────────────────────────────────────────────────────────
173
+
174
+ /**
175
+ * Parse a single TTL file and extract typed records.
176
+ */
177
+ export async function parseDataFile(filePath: string): Promise<{
178
+ records: Array<{
179
+ id: string;
180
+ type: string;
181
+ label: string | undefined;
182
+ properties: Record<string, string>;
183
+ }>;
184
+ totalQuads: number;
185
+ error?: string;
186
+ }> {
187
+ const result = await parseTurtleFile(filePath);
188
+ if (!result.success) {
189
+ return { records: [], totalQuads: 0, error: result.errors.join('; ') };
190
+ }
191
+
192
+ const records: Array<{
193
+ id: string;
194
+ type: string;
195
+ label: string | undefined;
196
+ properties: Record<string, string>;
197
+ }> = [];
198
+
199
+ for (const subject of result.subjects) {
200
+ // Skip blank nodes that are just structural (e.g., nested blank nodes for provenance)
201
+ // Keep named subjects (URNs, URIs) and typed blank nodes with meaningful types
202
+ const meaningfulTypes = subject.types.filter(
203
+ (t) =>
204
+ !t.startsWith('http://www.w3.org/ns/prov#') &&
205
+ t !== 'http://www.w3.org/ns/solid/terms#TypeRegistration' &&
206
+ t !== 'http://www.w3.org/ns/solid/terms#TypeIndex' &&
207
+ t !== 'http://www.w3.org/ns/solid/terms#ListedDocument' &&
208
+ t !== 'http://www.w3.org/ns/solid/terms#UnlistedDocument' &&
209
+ t !== 'http://www.w3.org/ns/ldp#BasicContainer',
210
+ );
211
+
212
+ if (meaningfulTypes.length === 0) continue;
213
+
214
+ const props = getProperties(result.store, subject.uri);
215
+ const label = extractLabel(props);
216
+
217
+ // Flatten properties for display (take first value of each, shorten IRIs)
218
+ const flatProps: Record<string, string> = {};
219
+ for (const [pred, values] of Object.entries(props)) {
220
+ const shortPred = shortenIRI(pred);
221
+ // Skip rdf:type since we have it separately
222
+ if (pred === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type') continue;
223
+ flatProps[shortPred] = values.length === 1 ? values[0] : values.join(', ');
224
+ }
225
+
226
+ records.push({
227
+ id: subject.uri,
228
+ type: shortenIRI(meaningfulTypes[0]),
229
+ label,
230
+ properties: flatProps,
231
+ });
232
+ }
233
+
234
+ return { records, totalQuads: result.quadCount };
235
+ }
236
+
237
+ /**
238
+ * Read the patient profile from a pod to extract name, age, schema version.
239
+ */
240
+ export async function readPatientProfile(podDir: string): Promise<{
241
+ name?: string;
242
+ age?: string;
243
+ schemaVersion?: string;
244
+ dateOfBirth?: string;
245
+ }> {
246
+ // Try clinical/patient-profile.ttl first, then profile/card.ttl
247
+ const profilePaths = [
248
+ path.join(podDir, 'clinical', 'patient-profile.ttl'),
249
+ path.join(podDir, 'profile', 'card.ttl'),
250
+ ];
251
+
252
+ let name: string | undefined;
253
+ let age: string | undefined;
254
+ let schemaVersion: string | undefined;
255
+ let dateOfBirth: string | undefined;
256
+
257
+ for (const profilePath of profilePaths) {
258
+ if (!(await fileExists(profilePath))) continue;
259
+
260
+ const result = await parseTurtleFile(profilePath);
261
+ if (!result.success) continue;
262
+
263
+ for (const subject of result.subjects) {
264
+ const props = getProperties(result.store, subject.uri);
265
+ if (!name) {
266
+ name = props['http://xmlns.com/foaf/0.1/name']?.[0];
267
+ }
268
+ if (!age) {
269
+ age = props[CASCADE_NAMESPACES.cascade + 'computedAge']?.[0];
270
+ }
271
+ if (!schemaVersion) {
272
+ schemaVersion = props[CASCADE_NAMESPACES.cascade + 'schemaVersion']?.[0];
273
+ }
274
+ if (!dateOfBirth) {
275
+ dateOfBirth = props[CASCADE_NAMESPACES.cascade + 'dateOfBirth']?.[0];
276
+ }
277
+ }
278
+ }
279
+
280
+ return { name, age, schemaVersion, dateOfBirth };
281
+ }
282
+
283
+ // ─── Display Helpers ─────────────────────────────────────────────────────────
284
+
285
+ /**
286
+ * Normalize provenance label for consistent display.
287
+ * Converts "core:ClinicalGenerated" to "cascade:ClinicalGenerated" since
288
+ * the "core" and "cascade" prefixes map to the same namespace.
289
+ */
290
+ export function normalizeProvenanceLabel(label: string): string {
291
+ if (label.startsWith('core:')) {
292
+ return 'cascade:' + label.slice(5);
293
+ }
294
+ return label;
295
+ }
296
+
297
+ /**
298
+ * Extract a display label from already-shortened property keys.
299
+ */
300
+ export function extractLabelFromProps(properties: Record<string, string>): string | undefined {
301
+ const labelKeys = [
302
+ 'health:medicationName',
303
+ 'health:conditionName',
304
+ 'health:allergen',
305
+ 'clinical:supplementName',
306
+ 'clinical:vaccineName',
307
+ 'health:vaccineName',
308
+ 'health:testName',
309
+ 'health:labTestName',
310
+ 'foaf:name',
311
+ 'dcterms:title',
312
+ ];
313
+
314
+ for (const key of labelKeys) {
315
+ if (properties[key]) {
316
+ return properties[key];
317
+ }
318
+ }
319
+ return undefined;
320
+ }
321
+
322
+ /**
323
+ * Select the most relevant properties for display based on data type.
324
+ */
325
+ export function selectKeyProperties(
326
+ typeName: string,
327
+ properties: Record<string, string>,
328
+ ): Record<string, string> {
329
+ const result: Record<string, string> = {};
330
+
331
+ // Common properties to always show if present
332
+ const commonKeys = ['cascade:dataProvenance', 'cascade:schemaVersion'];
333
+
334
+ // Type-specific key properties
335
+ const typeKeys: Record<string, string[]> = {
336
+ medications: [
337
+ 'health:dose',
338
+ 'health:frequency',
339
+ 'health:route',
340
+ 'health:isActive',
341
+ 'health:startDate',
342
+ 'health:prescriber',
343
+ 'health:rxNormCode',
344
+ 'health:medicationClass',
345
+ ],
346
+ conditions: [
347
+ 'health:status',
348
+ 'health:onsetDate',
349
+ 'health:icd10Code',
350
+ 'health:snomedCode',
351
+ 'health:conditionClass',
352
+ ],
353
+ allergies: [
354
+ 'health:allergyCategory',
355
+ 'health:reaction',
356
+ 'health:allergySeverity',
357
+ 'health:onsetDate',
358
+ ],
359
+ 'lab-results': [
360
+ 'health:value',
361
+ 'health:unit',
362
+ 'health:referenceRange',
363
+ 'health:interpretation',
364
+ 'health:effectiveDate',
365
+ ],
366
+ immunizations: [
367
+ 'health:vaccineDate',
368
+ 'health:lotNumber',
369
+ 'health:site',
370
+ 'health:manufacturer',
371
+ ],
372
+ supplements: [
373
+ 'clinical:dose',
374
+ 'clinical:frequency',
375
+ 'clinical:form',
376
+ 'clinical:isActive',
377
+ 'clinical:evidenceStrength',
378
+ ],
379
+ };
380
+
381
+ const keysToShow = [...(typeKeys[typeName] ?? []), ...commonKeys];
382
+
383
+ for (const key of keysToShow) {
384
+ if (properties[key]) {
385
+ result[key] = properties[key];
386
+ }
387
+ }
388
+
389
+ // If no specific keys matched, show first few properties
390
+ if (Object.keys(result).length === 0) {
391
+ const allKeys = Object.keys(properties);
392
+ for (const key of allKeys.slice(0, 5)) {
393
+ result[key] = properties[key];
394
+ }
395
+ }
396
+
397
+ return result;
398
+ }
399
+
400
+ // ─── Export Helpers ──────────────────────────────────────────────────────────
401
+
402
+ /**
403
+ * Recursively copy a directory.
404
+ */
405
+ export async function copyDirectory(src: string, dest: string): Promise<void> {
406
+ await fs.mkdir(dest, { recursive: true });
407
+ const entries = await fs.readdir(src, { withFileTypes: true });
408
+
409
+ for (const entry of entries) {
410
+ const srcPath = path.join(src, entry.name);
411
+ const destPath = path.join(dest, entry.name);
412
+
413
+ if (entry.isDirectory()) {
414
+ await copyDirectory(srcPath, destPath);
415
+ } else {
416
+ await fs.copyFile(srcPath, destPath);
417
+ }
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Create a ZIP archive of the pod directory using the archiver package.
423
+ */
424
+ export async function createZipArchive(sourceDir: string, outputPath: string): Promise<void> {
425
+ // Dynamic import of archiver
426
+ let archiverModule: { default: (format: string, options?: Record<string, unknown>) => import('archiver').Archiver };
427
+ try {
428
+ archiverModule = await import('archiver') as typeof archiverModule;
429
+ } catch {
430
+ throw new Error(
431
+ 'The "archiver" package is required for ZIP export. ' +
432
+ 'Install it with: npm install archiver',
433
+ );
434
+ }
435
+
436
+ const { createWriteStream } = await import('fs');
437
+
438
+ return new Promise((resolve, reject) => {
439
+ const output = createWriteStream(outputPath);
440
+ const archive = archiverModule.default('zip', { zlib: { level: 9 } });
441
+
442
+ output.on('close', () => resolve());
443
+ archive.on('error', (err: Error) => reject(err));
444
+
445
+ archive.pipe(output);
446
+ archive.directory(sourceDir, path.basename(sourceDir));
447
+ void archive.finalize();
448
+ });
449
+ }